@prisma-next/sql-contract-psl 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -21,6 +21,7 @@ import type {
21
21
  ControlMutationDefaults,
22
22
  MutationDefaultGeneratorDescriptor,
23
23
  } from '@prisma-next/framework-components/control';
24
+ import type { Namespace } from '@prisma-next/framework-components/ir';
24
25
  import type {
25
26
  ParsePslDocumentResult,
26
27
  PslAttribute,
@@ -29,10 +30,12 @@ import type {
29
30
  PslField,
30
31
  PslModel,
31
32
  PslNamedTypeDeclaration,
33
+ PslNamespace,
32
34
  } from '@prisma-next/psl-parser';
33
35
  import {
34
36
  isPostgresEnumStorageEntry,
35
37
  type PostgresEnumStorageEntry,
38
+ type SqlNamespaceTablesInput,
36
39
  type StorageTypeInstance,
37
40
  } from '@prisma-next/sql-contract/types';
38
41
  import {
@@ -92,6 +95,16 @@ export interface InterpretPslDocumentToSqlContractInput {
92
95
  readonly composedExtensionPackRefs?: readonly ExtensionPackRef<'sql', string>[];
93
96
  readonly controlMutationDefaults?: ControlMutationDefaults;
94
97
  readonly authoringContributions?: AuthoringContributions;
98
+ /**
99
+ * Target-supplied `Namespace` factory threaded into
100
+ * `buildSqlContractFromDefinition` for the contract's
101
+ * `SqlStorage.namespaces` population. Required when the document
102
+ * contains any explicit `namespace { … }` block on Postgres; the
103
+ * single-namespace path (top-level declarations only) stays valid
104
+ * without the factory and falls back to the family
105
+ * `SqlUnboundNamespace` singleton.
106
+ */
107
+ readonly createNamespace?: (input: SqlNamespaceTablesInput) => Namespace;
95
108
  }
96
109
 
97
110
  function buildComposedExtensionPackRefs(
@@ -160,6 +173,115 @@ function mapParserDiagnostics(document: ParsePslDocumentResult): ContractSourceD
160
173
  }));
161
174
  }
162
175
 
176
+ /**
177
+ * Name of the framework-parser synthesised bucket for top-level
178
+ * declarations. Re-declared here so the per-target dispatch does not
179
+ * have to import from `@prisma-next/framework-components/psl-ast`
180
+ * (which would cross a layer that the interpreter does not otherwise
181
+ * import from). The value is part of the framework parser's contract;
182
+ * if it changes there, the matching test in this package's
183
+ * `interpreter.diagnostics.test.ts` flips first.
184
+ */
185
+ const UNSPECIFIED_PSL_NAMESPACE_NAME = '__unspecified__';
186
+
187
+ /**
188
+ * Per-target namespace-block validation: walk the AST's namespace buckets and
189
+ * emit diagnostics for syntactic constructs the target does not accept.
190
+ *
191
+ * - **SQLite** has no schema concept and rejects every explicit
192
+ * `namespace { … }` block. The implicit `__unspecified__` bucket
193
+ * (produced by the parser for top-level declarations outside any
194
+ * block) is the only namespace SQLite accepts.
195
+ * - **Postgres** accepts every explicit block — `namespace unbound { … }`
196
+ * is the late-binding opt-in (lowers to the IR `__unbound__` slot in
197
+ * a follow-on commit), `namespace public { … }` reopen-merges with
198
+ * the implicit bucket, and any other name lowers to a named schema.
199
+ *
200
+ * Storage-side lowering of these buckets to IR namespace slots is not
201
+ * yet wired; this helper closes only the diagnostic surface.
202
+ */
203
+ /**
204
+ * Per-target namespace lowering: map a PSL AST namespace bucket name to the
205
+ * resolved IR namespace id (the key downstream consumers use against
206
+ * `SqlStorage.namespaces`).
207
+ *
208
+ * - **Postgres**: an explicit `namespace unbound { … }` block lowers
209
+ * to the framework sentinel `__unbound__` — the slot whose binding
210
+ * the connection's `search_path` resolves at runtime. Every other
211
+ * explicit bucket name (e.g. `auth`, `public`) passes through as a
212
+ * named schema id. The implicit `__unspecified__` bucket — top-level
213
+ * declarations outside any `namespace { … }` block — leaves the
214
+ * coordinate unset; downstream consumers treat unset as the
215
+ * late-bound default, and TS / PSL authoring stay byte-identical
216
+ * on single-namespace contracts. (A future round will add a
217
+ * target-default-namespace surface so `__unspecified__` lowers to
218
+ * `public` consistently on both authoring paths.)
219
+ * - **SQLite**: SQLite has no schema concept; every namespace
220
+ * collapses to the late-bound default. The namespace-block
221
+ * validation step (above) has already rejected any explicit
222
+ * `namespace { … }` block on SQLite, so the only bucket the
223
+ * lowering ever sees there is `__unspecified__`.
224
+ *
225
+ * Returns `undefined` for targets / bucket names with no explicit
226
+ * namespaceId to assign — callers leave the model's `namespaceId`
227
+ * slot empty (which means the late-bound default at the `StorageTable`
228
+ * layer; emitted JSON omits the field).
229
+ */
230
+ function resolveNamespaceIdForSqlTarget(input: {
231
+ readonly bucketName: string;
232
+ readonly targetId: string;
233
+ }): string | undefined {
234
+ if (input.targetId !== 'postgres') {
235
+ return undefined;
236
+ }
237
+ if (input.bucketName === UNSPECIFIED_PSL_NAMESPACE_NAME) {
238
+ return undefined;
239
+ }
240
+ if (input.bucketName === 'unbound') {
241
+ return '__unbound__';
242
+ }
243
+ return input.bucketName;
244
+ }
245
+
246
+ function validateNamespaceBlocksForSqlTarget(input: {
247
+ readonly namespaces: readonly PslNamespace[];
248
+ readonly targetId: string;
249
+ readonly sourceId: string;
250
+ readonly diagnostics: ContractSourceDiagnostic[];
251
+ }): void {
252
+ if (input.targetId === 'sqlite') {
253
+ for (const namespace of input.namespaces) {
254
+ if (namespace.name === UNSPECIFIED_PSL_NAMESPACE_NAME) {
255
+ continue;
256
+ }
257
+ input.diagnostics.push({
258
+ code: 'PSL_UNSUPPORTED_NAMESPACE_BLOCK',
259
+ message: `SQLite does not support \`namespace ${namespace.name} { … }\` blocks (SQLite has no schema concept; declare models at the document top level instead).`,
260
+ sourceId: input.sourceId,
261
+ span: namespace.span,
262
+ });
263
+ }
264
+ return;
265
+ }
266
+
267
+ if (input.targetId === 'postgres') {
268
+ const namedBlocks = input.namespaces.filter((ns) => ns.name !== UNSPECIFIED_PSL_NAMESPACE_NAME);
269
+ const hasUnbound = namedBlocks.some((ns) => ns.name === 'unbound');
270
+ const hasSibling = namedBlocks.some((ns) => ns.name !== 'unbound');
271
+ if (hasUnbound && hasSibling) {
272
+ const unboundBlock = namedBlocks.find((ns) => ns.name === 'unbound');
273
+ input.diagnostics.push({
274
+ code: 'PSL_RESERVED_NAMESPACE_NAME',
275
+ message:
276
+ 'Namespace "unbound" is reserved for the late-binding sentinel mapping and cannot appear alongside other named namespace blocks. ' +
277
+ 'Use `namespace unbound { … }` alone (no sibling named namespaces) for late-binding multi-tenant contracts.',
278
+ sourceId: input.sourceId,
279
+ ...ifDefined('span', unboundBlock?.span),
280
+ });
281
+ }
282
+ }
283
+ }
284
+
163
285
  interface ProcessEnumDeclarationsInput {
164
286
  readonly enums: readonly PslEnum[];
165
287
  readonly sourceId: string;
@@ -468,6 +590,8 @@ interface BuildModelNodeInput {
468
590
  readonly scalarTypeDescriptors: ReadonlyMap<string, ColumnDescriptor>;
469
591
  readonly sourceId: string;
470
592
  readonly diagnostics: ContractSourceDiagnostic[];
593
+ /** Resolved namespace id keyed by model name — used to stamp the target namespace on FKs. */
594
+ readonly modelNamespaceIds: ReadonlyMap<string, string>;
471
595
  }
472
596
 
473
597
  interface BuildModelNodeResult {
@@ -812,16 +936,37 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
812
936
  continue;
813
937
  }
814
938
 
815
- if (!input.modelNames.has(relationAttribute.field.typeName)) {
939
+ const { typeName: fieldTypeName, typeNamespaceId: fieldTypeNamespaceId } =
940
+ relationAttribute.field;
941
+ const qualifiedTypeName = fieldTypeNamespaceId
942
+ ? `${fieldTypeNamespaceId}.${fieldTypeName}`
943
+ : fieldTypeName;
944
+
945
+ if (!input.modelNames.has(fieldTypeName)) {
816
946
  diagnostics.push({
817
947
  code: 'PSL_INVALID_RELATION_TARGET',
818
- message: `Relation field "${model.name}.${relationAttribute.field.name}" references unknown model "${relationAttribute.field.typeName}"`,
948
+ message: `Relation field "${model.name}.${relationAttribute.field.name}" references unknown model "${qualifiedTypeName}"`,
819
949
  sourceId,
820
950
  span: relationAttribute.field.span,
821
951
  });
822
952
  continue;
823
953
  }
824
954
 
955
+ if (fieldTypeNamespaceId !== undefined) {
956
+ const resolvedTargetNamespaceId = input.modelNamespaceIds.get(fieldTypeName);
957
+ const normalizedQualifier =
958
+ fieldTypeNamespaceId === 'unbound' ? '__unbound__' : fieldTypeNamespaceId;
959
+ if (resolvedTargetNamespaceId !== normalizedQualifier) {
960
+ diagnostics.push({
961
+ code: 'PSL_INVALID_RELATION_TARGET',
962
+ message: `Relation field "${model.name}.${relationAttribute.field.name}" references unknown model "${qualifiedTypeName}"`,
963
+ sourceId,
964
+ span: relationAttribute.field.span,
965
+ });
966
+ continue;
967
+ }
968
+ }
969
+
825
970
  const parsedRelation = parseRelationAttribute({
826
971
  attribute: relationAttribute.relation,
827
972
  modelName: model.name,
@@ -842,11 +987,11 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
842
987
  continue;
843
988
  }
844
989
 
845
- const targetMapping = input.modelMappings.get(relationAttribute.field.typeName);
990
+ const targetMapping = input.modelMappings.get(fieldTypeName);
846
991
  if (!targetMapping) {
847
992
  diagnostics.push({
848
993
  code: 'PSL_INVALID_RELATION_TARGET',
849
- message: `Relation field "${model.name}.${relationAttribute.field.name}" references unknown model "${relationAttribute.field.typeName}"`,
994
+ message: `Relation field "${model.name}.${relationAttribute.field.name}" references unknown model "${qualifiedTypeName}"`,
850
995
  sourceId,
851
996
  span: relationAttribute.field.span,
852
997
  });
@@ -910,12 +1055,14 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
910
1055
  })
911
1056
  : undefined;
912
1057
 
1058
+ const targetNamespaceId = input.modelNamespaceIds.get(targetMapping.model.name);
913
1059
  foreignKeyNodes.push({
914
1060
  columns: localColumns,
915
1061
  references: {
916
1062
  model: targetMapping.model.name,
917
1063
  table: targetMapping.tableName,
918
1064
  columns: referencedColumns,
1065
+ ...ifDefined('namespaceId', targetNamespaceId),
919
1066
  },
920
1067
  ...ifDefined('name', parsedRelation.constraintName),
921
1068
  ...ifDefined('onDelete', onDelete),
@@ -1298,9 +1445,61 @@ export function interpretPslDocumentToSqlContract(
1298
1445
  }
1299
1446
 
1300
1447
  const diagnostics: ContractSourceDiagnostic[] = mapParserDiagnostics(input.document);
1301
- const models = input.document.ast.models ?? [];
1302
- const enums = input.document.ast.enums ?? [];
1303
- const compositeTypes = input.document.ast.compositeTypes ?? [];
1448
+ validateNamespaceBlocksForSqlTarget({
1449
+ namespaces: input.document.ast.namespaces,
1450
+ targetId: input.target.targetId,
1451
+ sourceId,
1452
+ diagnostics,
1453
+ });
1454
+ // Per-target namespace resolution: walk each AST bucket once,
1455
+ // recording every model's resolved `namespaceId` for later threading
1456
+ // into the `ModelNode` build. The resolution rules are target-local
1457
+ // (see `resolveNamespaceIdForSqlTarget`); the flattened model list
1458
+ // remains the input to the rest of the interpreter so non-namespace
1459
+ // concerns stay structurally identical to before.
1460
+ const models: PslModel[] = [];
1461
+ const modelNamespaceIds = new Map<string, string>();
1462
+ for (const namespace of input.document.ast.namespaces) {
1463
+ const resolvedNamespaceId = resolveNamespaceIdForSqlTarget({
1464
+ bucketName: namespace.name,
1465
+ targetId: input.target.targetId,
1466
+ });
1467
+ for (const model of namespace.models) {
1468
+ models.push(model);
1469
+ if (resolvedNamespaceId !== undefined) {
1470
+ modelNamespaceIds.set(model.name, resolvedNamespaceId);
1471
+ }
1472
+ }
1473
+ }
1474
+ // Top-level enums (the __unspecified__ bucket) route to `storageTypes`;
1475
+ // enums inside a named namespace block route to `namespaceTypes[nsId]`.
1476
+ const topLevelEnums = input.document.ast.namespaces
1477
+ .filter((ns) => ns.name === UNSPECIFIED_PSL_NAMESPACE_NAME)
1478
+ .flatMap((ns) => ns.enums);
1479
+ const namedNamespaceEnumsByNsId = new Map<string, readonly PslEnum[]>();
1480
+ for (const ns of input.document.ast.namespaces) {
1481
+ if (ns.name === UNSPECIFIED_PSL_NAMESPACE_NAME || ns.enums.length === 0) {
1482
+ continue;
1483
+ }
1484
+ const resolvedId = resolveNamespaceIdForSqlTarget({
1485
+ bucketName: ns.name,
1486
+ targetId: input.target.targetId,
1487
+ });
1488
+ if (resolvedId === undefined) {
1489
+ continue;
1490
+ }
1491
+ // Read-then-merge so that any future change to the PSL parser (or to
1492
+ // `resolveNamespaceIdForSqlTarget`) that produces two AST entries
1493
+ // resolving to the same `resolvedId` would accumulate their enums
1494
+ // rather than silently dropping the earlier set. Today the parser
1495
+ // already merges duplicate `namespace <name> { … }` blocks into a
1496
+ // single AST entry per name, so this loop sees one `ns` per
1497
+ // resolvedId and the merge degrades to a plain set.
1498
+ const existing = namedNamespaceEnumsByNsId.get(resolvedId) ?? [];
1499
+ namedNamespaceEnumsByNsId.set(resolvedId, [...existing, ...ns.enums]);
1500
+ }
1501
+
1502
+ const compositeTypes = input.document.ast.namespaces.flatMap((ns) => ns.compositeTypes);
1304
1503
  const modelNames = new Set(models.map((model) => model.name));
1305
1504
  const compositeTypeNames = new Set(compositeTypes.map((ct) => ct.name));
1306
1505
  const composedExtensions = new Set(input.composedExtensionPacks ?? []);
@@ -1312,21 +1511,50 @@ export function interpretPslDocumentToSqlContract(
1312
1511
  generatorDescriptorById.set(descriptor.id, descriptor);
1313
1512
  }
1314
1513
 
1514
+ const enumEntityDescriptor = getAuthoringEntity(input.authoringContributions, ['enum']);
1515
+ const enumEntityContext = {
1516
+ family: input.target.familyId,
1517
+ target: input.target.targetId,
1518
+ };
1519
+
1315
1520
  const enumResult = processEnumDeclarations({
1316
- enums,
1521
+ enums: topLevelEnums,
1317
1522
  sourceId,
1318
- enumEntityDescriptor: getAuthoringEntity(input.authoringContributions, ['enum']),
1319
- entityContext: {
1320
- family: input.target.familyId,
1321
- target: input.target.targetId,
1322
- },
1523
+ enumEntityDescriptor,
1524
+ entityContext: enumEntityContext,
1323
1525
  diagnostics,
1324
1526
  });
1325
1527
 
1528
+ // Process enums declared in named namespace blocks and collect them into
1529
+ // `namespaceTypes` keyed by the resolved namespace id.
1530
+ const allEnumTypeDescriptors = new Map(enumResult.enumTypeDescriptors);
1531
+ const namespaceEnumStorageTypes: Record<string, Record<string, PostgresEnumStorageEntry>> = {};
1532
+ for (const [nsId, nsEnums] of namedNamespaceEnumsByNsId) {
1533
+ const nsEnumResult = processEnumDeclarations({
1534
+ enums: nsEnums,
1535
+ sourceId,
1536
+ enumEntityDescriptor,
1537
+ entityContext: enumEntityContext,
1538
+ diagnostics,
1539
+ });
1540
+ for (const [name, descriptor] of nsEnumResult.enumTypeDescriptors) {
1541
+ allEnumTypeDescriptors.set(name, descriptor);
1542
+ }
1543
+ const nsEntries: Record<string, PostgresEnumStorageEntry> = {};
1544
+ for (const [name, entry] of Object.entries(nsEnumResult.storageTypes)) {
1545
+ if (isPostgresEnumStorageEntry(entry)) {
1546
+ nsEntries[name] = entry;
1547
+ }
1548
+ }
1549
+ if (Object.keys(nsEntries).length > 0) {
1550
+ namespaceEnumStorageTypes[nsId] = nsEntries;
1551
+ }
1552
+ }
1553
+
1326
1554
  const namedTypeResult = resolveNamedTypeDeclarations({
1327
1555
  declarations: input.document.ast.types?.declarations ?? [],
1328
1556
  sourceId,
1329
- enumTypeDescriptors: enumResult.enumTypeDescriptors,
1557
+ enumTypeDescriptors: allEnumTypeDescriptors,
1330
1558
  scalarTypeDescriptors: input.scalarTypeDescriptors,
1331
1559
  composedExtensions,
1332
1560
  familyId: input.target.familyId,
@@ -1354,7 +1582,7 @@ export function interpretPslDocumentToSqlContract(
1354
1582
  modelMappings,
1355
1583
  modelNames,
1356
1584
  compositeTypeNames,
1357
- enumTypeDescriptors: enumResult.enumTypeDescriptors,
1585
+ enumTypeDescriptors: allEnumTypeDescriptors,
1358
1586
  namedTypeDescriptors: namedTypeResult.namedTypeDescriptors,
1359
1587
  composedExtensions,
1360
1588
  familyId: input.target.familyId,
@@ -1365,8 +1593,14 @@ export function interpretPslDocumentToSqlContract(
1365
1593
  scalarTypeDescriptors: input.scalarTypeDescriptors,
1366
1594
  sourceId,
1367
1595
  diagnostics,
1596
+ modelNamespaceIds,
1368
1597
  });
1369
- modelNodes.push(result.modelNode);
1598
+ const resolvedNamespaceId = modelNamespaceIds.get(model.name);
1599
+ modelNodes.push(
1600
+ resolvedNamespaceId !== undefined
1601
+ ? { ...result.modelNode, namespaceId: resolvedNamespaceId }
1602
+ : result.modelNode,
1603
+ );
1370
1604
  fkRelationMetadata.push(...result.fkRelationMetadata);
1371
1605
  backrelationCandidates.push(...result.backrelationCandidates);
1372
1606
  modelResolvedFields.set(model.name, result.resolvedFields);
@@ -1389,7 +1623,7 @@ export function interpretPslDocumentToSqlContract(
1389
1623
 
1390
1624
  const valueObjects = buildValueObjects({
1391
1625
  compositeTypes,
1392
- enumTypeDescriptors: enumResult.enumTypeDescriptors,
1626
+ enumTypeDescriptors: allEnumTypeDescriptors,
1393
1627
  namedTypeDescriptors: namedTypeResult.namedTypeDescriptors,
1394
1628
  scalarTypeDescriptors: input.scalarTypeDescriptors,
1395
1629
  composedExtensions,
@@ -1418,6 +1652,10 @@ export function interpretPslDocumentToSqlContract(
1418
1652
  ),
1419
1653
  ),
1420
1654
  ...(Object.keys(storageTypes).length > 0 ? { storageTypes } : {}),
1655
+ ...(Object.keys(namespaceEnumStorageTypes).length > 0
1656
+ ? { namespaceTypes: namespaceEnumStorageTypes }
1657
+ : {}),
1658
+ ...ifDefined('createNamespace', input.createNamespace),
1421
1659
  models: modelNodes.map((model) => ({
1422
1660
  ...model,
1423
1661
  ...(modelRelations.has(model.modelName)