Skip to content

Commit 34ae7d6

Browse files
SevInfclaude
andauthored
tml-2912: PSL native scalar lists (author, migrate, infer end-to-end) (#870)
## tml-2912 — PSL native scalar lists (slice 2 of native-scalar-arrays) Flips the implicit gate slice 1 built behind: a user can now author SQL scalar lists in PSL (`tags String[]`) and get them **end to end** — native Postgres array columns (`text[]`), element-wise codec round-trip, authoring diagnostics, capability gating, and the headline `posts.tags String[]` milestone checkpoint (project AC1). Stacked on slice 1 (#846, merged). The load-bearing change is one line in the interpreter — replacing the JSONB fallback with the element descriptor + `many: true`; the rest is making the authoring layer correct about lists. ### Dispatches - **D1** — interpreter flip: PSL scalar lists lower to native array storage columns (the JSONB fallback turned out to live in **3** coordinated sites, all removed). - **D1b** — make `StorageColumnTypes`/`StorageColumnInputTypes` `many`-aware (the insert/select builders consume the storage maps; only the domain maps were wrapped before). - **D2** — reject incoherent list constructs (execution default / `@id` / `autoincrement` on a list) with actionable diagnostics. - **D3** — authoring-time capability gating: a list field against a target whose adapter doesn't report `scalarList` (SQLite) is rejected; threads a merged `CapabilityMatrix` to the interpreter via the existing `mergeCapabilityMatrices`. - **D4** — element-non-null `CHECK (array_position(col, NULL) IS NULL)` (enforce-on-all) + array-typed `@default([])`/`@default([...])` validation; rejects a scalar default on a list. - **D5** — end-to-end milestone (AC1 author→migrate→infer round-trip) + element fidelity (`DateTime[]`/`Bytes[]`/`Decimal[]`) + Mongo parity (NFR4). ### Acceptance criteria AC1 (milestone round-trip), AC6 (FR3 diagnostics), AC8 PSL-half (non-null-element CHECK), AC9 (array defaults), AC10 (capability gating), AC2/NFR1 (element fidelity), NFR4 (Mongo parity), NFR2 (no scalar regression — `fixtures:check` byte-clean). **9/9 PASS.** ### Notes for reviewers - **Element nullability is enforce-on-all.** PSL has no `T?[]` (element-nullable) syntax, so every PSL-authored list column carries the non-null-element CHECK; no `elementNullable` IR flag was needed. - **CHECK is emitted as DDL, not via ADR-156.** ADR-156 check constraints are value-set/`IN(...)`-only and can't express `array_position(...)`, so D4 added a small `CheckExpressionConstraint` DDL node (`<table>_<col>_elem_not_null`). - **Known limitation (carried to slice 3):** opt-in `--strict` verify would false-flag the element-non-null CHECK as drift, because introspection doesn't parse it back. Default verify is non-strict and unaffected; the AC1 infer round-trip is clean (`text[]` → `String[]`). 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added support for scalar list fields across authoring, schema generation, migrations, and contract emission. * Scalar lists now use native array storage where supported, with updated typing and round-trip behavior. * Improved check-constraint handling to support both value-set and expression-based validations. * **Bug Fixes** * Fixed list default parsing, including quoted values and special characters. * Improved validation for unsupported list features and stricter schema verification for array fields. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Signed-off-by: Serhii Tatarintsev <tatarintsev@prisma.io> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 228931d commit 34ae7d6

76 files changed

Lines changed: 2196 additions & 52 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/telemetry-backend/src/prisma/contract.d.ts

Lines changed: 6 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/telemetry-backend/src/prisma/contract.json

Lines changed: 2 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/telemetry-backend/src/prisma/contract.prisma

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ model TelemetryEvent {
66
installationId String
77
version String
88
command String
9-
flags String[]
9+
flags Json
1010
runtimeName String
1111
runtimeVersion String
1212
os String
@@ -15,7 +15,7 @@ model TelemetryEvent {
1515
databaseTarget String?
1616
tsVersion String?
1717
agent String?
18-
extensions String[]
18+
extensions Json
1919
2020
@@index([ingestedAt])
2121
@@map("telemetry_event")

apps/telemetry-backend/test/handler.test.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -162,10 +162,8 @@ describe('telemetry POST /events', () => {
162162
const row = await fetchSingleRow();
163163
expect(row.installationId).toBe(maxString);
164164
expect(row.command).toBe(maxString);
165-
expect(row.flags).toHaveLength(64);
166-
expect(row.flags[0]).toBe(maxArrayItem);
167-
expect(row.extensions).toHaveLength(64);
168-
expect(row.extensions[0]).toBe(maxArrayItem);
165+
expect(row.flags).toEqual(manyArrayItems);
166+
expect(row.extensions).toEqual(manyArrayItems);
169167
});
170168

171169
it('rejects strings beyond the configured schema bounds with 400', async () => {

packages/1-framework/1-core/config/src/contract-source-types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Contract } from '@prisma-next/contract/types';
22
import type { CodecLookup } from '@prisma-next/framework-components/codec';
3+
import type { CapabilityMatrix } from '@prisma-next/framework-components/components';
34
import type {
45
AssembledAuthoringContributions,
56
ControlMutationDefaults,
@@ -45,6 +46,7 @@ export interface ContractSourceContext {
4546
readonly codecLookup: CodecLookup;
4647
readonly controlMutationDefaults: ControlMutationDefaults;
4748
readonly resolvedInputs: readonly string[];
49+
readonly capabilities: CapabilityMatrix;
4850
}
4951

5052
/** Lets format-aware tooling avoid file-extension sniffing and opaque loader introspection. */

packages/1-framework/1-core/framework-components/src/control/control-stack.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import type { JsonValue } from '@prisma-next/contract/types';
22
import { blindCast } from '@prisma-next/utils/casts';
3+
import type { CapabilityMatrix } from '../shared/capabilities';
4+
import { mergeCapabilityMatrices } from '../shared/capabilities';
35
import type { Codec } from '../shared/codec';
46
import type { AnyCodecDescriptor } from '../shared/codec-descriptor';
57
import type { CodecLookup, CodecMeta, CodecRef, CodecRegistry } from '../shared/codec-types';
@@ -62,6 +64,7 @@ export interface ControlStack<
6264
readonly authoringContributions: AssembledAuthoringContributions;
6365
readonly scalarTypeDescriptors: ReadonlyMap<string, string>;
6466
readonly controlMutationDefaults: ControlMutationDefaults;
67+
readonly capabilities: CapabilityMatrix;
6568
}
6669

6770
export interface CreateControlStackInput<
@@ -522,5 +525,10 @@ export function createControlStack<TFamilyId extends string, TTargetId extends s
522525
authoringContributions: assembleAuthoringContributions(allDescriptors),
523526
scalarTypeDescriptors,
524527
controlMutationDefaults: assembleControlMutationDefaults(allDescriptors),
528+
capabilities: mergeCapabilityMatrices({}, [
529+
target,
530+
...(adapter ? [adapter] : []),
531+
...orderedExtensionPacks,
532+
]),
525533
};
526534
}

packages/1-framework/1-core/framework-components/src/exports/components.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export type { CapabilityMatrix } from '../shared/capabilities';
12
export { mergeCapabilityMatrices } from '../shared/capabilities';
23
export type {
34
AdapterDescriptor,

packages/1-framework/1-core/framework-components/src/shared/capabilities.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
import { blindCast } from '@prisma-next/utils/casts';
1111

12-
type CapabilityMatrix = Record<string, Record<string, boolean>>;
12+
export type CapabilityMatrix = Record<string, Record<string, boolean>>;
1313

1414
function isPlainObject(value: unknown): value is Record<string, unknown> {
1515
return typeof value === 'object' && value !== null && !Array.isArray(value);

packages/1-framework/2-authoring/psl-parser/src/resolve.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export interface ResolvedAttributeArg {
1515
readonly kind: 'positional' | 'named';
1616
readonly name?: string;
1717
readonly value: string;
18+
readonly expression?: ExpressionAst;
1819
readonly span: PslSpan;
1920
}
2021

@@ -69,10 +70,12 @@ function readResolvedArgList(
6970
const args: ResolvedAttributeArg[] = [];
7071
for (const arg of argList.args()) {
7172
const name = arg.name()?.name();
73+
const expression = arg.value();
7274
args.push({
7375
kind: name !== undefined ? 'named' : 'positional',
7476
...(name !== undefined ? { name } : {}),
75-
value: renderExpression(arg.value()),
77+
value: renderExpression(expression),
78+
...(expression !== undefined ? { expression } : {}),
7679
span: nodePslSpan(arg.syntax, sourceFile),
7780
});
7881
}

packages/1-framework/3-tooling/cli/src/control-api/client.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -631,6 +631,7 @@ class ControlClientImpl implements ControlClient {
631631
codecLookup: stack.codecLookup,
632632
controlMutationDefaults: stack.controlMutationDefaults,
633633
resolvedInputs: contractConfig.source.inputs ?? [],
634+
capabilities: stack.capabilities,
634635
};
635636
const providerResult = await contractConfig.source.load(sourceContext);
636637
if (!providerResult.ok) {

0 commit comments

Comments
 (0)