@prisma-next/framework-components 0.12.0 → 0.13.0-dev.1

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.
Files changed (63) hide show
  1. package/dist/authoring.d.mts +2 -2
  2. package/dist/authoring.mjs +2 -2
  3. package/dist/{codec-BFOsuHKK.d.mts → codec-DCQAerzB.d.mts} +1 -1
  4. package/dist/{codec-BFOsuHKK.d.mts.map → codec-DCQAerzB.d.mts.map} +1 -1
  5. package/dist/codec.d.mts +1 -1
  6. package/dist/codec.d.mts.map +1 -1
  7. package/dist/components.d.mts +1 -1
  8. package/dist/components.mjs +1 -1
  9. package/dist/components.mjs.map +1 -1
  10. package/dist/control.d.mts +85 -13
  11. package/dist/control.d.mts.map +1 -1
  12. package/dist/control.mjs +89 -7
  13. package/dist/control.mjs.map +1 -1
  14. package/dist/{emission-types-CMv_053d.d.mts → emission-types-vfpSTe63.d.mts} +2 -2
  15. package/dist/{emission-types-CMv_053d.d.mts.map → emission-types-vfpSTe63.d.mts.map} +1 -1
  16. package/dist/emission.d.mts +3 -3
  17. package/dist/execution.d.mts +1 -1
  18. package/dist/execution.d.mts.map +1 -1
  19. package/dist/execution.mjs +1 -1
  20. package/dist/{framework-authoring-DcEZ5Lin.mjs → framework-authoring-CnwPJCO4.mjs} +76 -5
  21. package/dist/framework-authoring-CnwPJCO4.mjs.map +1 -0
  22. package/dist/framework-authoring-R0TYCkvG.d.mts +380 -0
  23. package/dist/framework-authoring-R0TYCkvG.d.mts.map +1 -0
  24. package/dist/{framework-components-CuoUhyB5.d.mts → framework-components-DDQXmW0b.d.mts} +6 -5
  25. package/dist/{framework-components-CuoUhyB5.d.mts.map → framework-components-DDQXmW0b.d.mts.map} +1 -1
  26. package/dist/{framework-components-FdqmlGUj.mjs → framework-components-DbCS57go.mjs} +1 -1
  27. package/dist/{framework-components-FdqmlGUj.mjs.map → framework-components-DbCS57go.mjs.map} +1 -1
  28. package/dist/ir.d.mts +20 -18
  29. package/dist/ir.d.mts.map +1 -1
  30. package/dist/ir.mjs +17 -14
  31. package/dist/ir.mjs.map +1 -1
  32. package/dist/{psl-ast-BDXL7iCg.d.mts → psl-ast-Cn50B-UG.d.mts} +90 -18
  33. package/dist/psl-ast-Cn50B-UG.d.mts.map +1 -0
  34. package/dist/psl-ast.d.mts +37 -2
  35. package/dist/psl-ast.d.mts.map +1 -0
  36. package/dist/psl-ast.mjs +222 -4
  37. package/dist/psl-ast.mjs.map +1 -1
  38. package/dist/runtime.d.mts +1 -1
  39. package/dist/runtime.d.mts.map +1 -1
  40. package/dist/runtime.mjs.map +1 -1
  41. package/dist/{types-import-spec-BxI5cSQy.d.mts → types-import-spec-DRKzrJ20.d.mts} +1 -1
  42. package/dist/{types-import-spec-BxI5cSQy.d.mts.map → types-import-spec-DRKzrJ20.d.mts.map} +1 -1
  43. package/dist/utils.mjs.map +1 -1
  44. package/package.json +9 -9
  45. package/src/control/control-instances.ts +15 -5
  46. package/src/control/control-migration-types.ts +20 -2
  47. package/src/control/control-result-types.ts +4 -1
  48. package/src/control/control-stack.ts +123 -4
  49. package/src/control/psl-ast.ts +234 -34
  50. package/src/control/psl-extension-block-validator.ts +324 -0
  51. package/src/control/verifier-disposition.ts +62 -0
  52. package/src/exports/authoring.ts +16 -0
  53. package/src/exports/control.ts +7 -0
  54. package/src/exports/psl-ast.ts +2 -0
  55. package/src/ir/namespace.ts +15 -13
  56. package/src/ir/storage.ts +8 -7
  57. package/src/shared/framework-authoring.ts +215 -2
  58. package/src/shared/framework-components.ts +2 -0
  59. package/src/shared/psl-extension-block.ts +184 -0
  60. package/dist/framework-authoring-BPPe9C9D.d.mts +0 -183
  61. package/dist/framework-authoring-BPPe9C9D.d.mts.map +0 -1
  62. package/dist/framework-authoring-DcEZ5Lin.mjs.map +0 -1
  63. package/dist/psl-ast-BDXL7iCg.d.mts.map +0 -1
@@ -13,7 +13,11 @@ import type { Contract } from '@prisma-next/contract/types';
13
13
  import type { ImportRequirement } from '@prisma-next/ts-render';
14
14
  import type { Result } from '@prisma-next/utils/result';
15
15
  import type { TargetBoundComponentDescriptor } from '../shared/framework-components';
16
- import type { ControlDriverInstance, ControlFamilyInstance } from './control-instances';
16
+ import type {
17
+ ControlAdapterInstance,
18
+ ControlDriverInstance,
19
+ ControlFamilyInstance,
20
+ } from './control-instances';
17
21
  import type { OperationContext } from './control-result-types';
18
22
 
19
23
  // ============================================================================
@@ -279,6 +283,7 @@ export interface MigrationPlannerConflict {
279
283
  export interface MigrationPlannerSuccessResult {
280
284
  readonly kind: 'success';
281
285
  readonly plan: MigrationPlanWithAuthoringSurface;
286
+ readonly warnings?: readonly MigrationPlannerConflict[];
282
287
  }
283
288
 
284
289
  /**
@@ -479,6 +484,17 @@ export interface MigrationRunnerPerSpaceOptions<
479
484
  * Paths and metadata forwarded to schema verification diagnostics.
480
485
  */
481
486
  readonly context?: OperationContext;
487
+ /**
488
+ * Per-edge breakdown from aggregate planning. Runners write one ledger row
489
+ * per edge in walk order.
490
+ */
491
+ readonly migrationEdges: ReadonlyArray<{
492
+ readonly migrationHash: string;
493
+ readonly dirName: string;
494
+ readonly from: string;
495
+ readonly to: string;
496
+ readonly operationCount: number;
497
+ }>;
482
498
  }
483
499
 
484
500
  export interface MigrationRunner<
@@ -525,7 +541,9 @@ export interface TargetMigrationsCapability<
525
541
  unknown
526
542
  >,
527
543
  > {
528
- createPlanner(family: TFamilyInstance): MigrationPlanner<TFamilyId, TTargetId>;
544
+ createPlanner(
545
+ adapter: ControlAdapterInstance<TFamilyId, TTargetId>,
546
+ ): MigrationPlanner<TFamilyId, TTargetId>;
529
547
  createRunner(family: TFamilyInstance): MigrationRunner<TFamilyId, TTargetId>;
530
548
  /**
531
549
  * Synthesizes a family-specific schema IR from a contract for offline planning.
@@ -58,7 +58,10 @@ export interface BaseSchemaIssue {
58
58
  | 'index_mismatch'
59
59
  | 'default_missing'
60
60
  | 'default_mismatch'
61
- | 'extra_default';
61
+ | 'extra_default'
62
+ | 'check_missing'
63
+ | 'check_removed'
64
+ | 'check_mismatch';
62
65
  readonly table?: string;
63
66
  /**
64
67
  * Namespace coordinate of the issue's subject (e.g. the schema a SQL
@@ -1,15 +1,18 @@
1
+ import { blindCast } from '@prisma-next/utils/casts';
1
2
  import type { Codec } from '../shared/codec';
2
3
  import type { CodecLookup, CodecMeta } from '../shared/codec-types';
3
4
  import type {
4
5
  AuthoringContributions,
5
6
  AuthoringEntityTypeNamespace,
6
7
  AuthoringFieldNamespace,
8
+ AuthoringPslBlockDescriptorNamespace,
7
9
  AuthoringTypeNamespace,
8
10
  } from '../shared/framework-authoring';
9
11
  import {
10
12
  assertNoCrossRegistryCollisions,
11
13
  isAuthoringEntityTypeDescriptor,
12
14
  isAuthoringFieldPresetDescriptor,
15
+ isAuthoringPslBlockDescriptor,
13
16
  isAuthoringTypeConstructorDescriptor,
14
17
  mergeAuthoringNamespaces,
15
18
  } from '../shared/framework-authoring';
@@ -32,6 +35,7 @@ export interface AssembledAuthoringContributions {
32
35
  readonly field: AuthoringFieldNamespace;
33
36
  readonly type: AuthoringTypeNamespace;
34
37
  readonly entityTypes: AuthoringEntityTypeNamespace;
38
+ readonly pslBlockDescriptors: AuthoringPslBlockDescriptorNamespace;
35
39
  }
36
40
 
37
41
  export interface ControlStack<
@@ -151,6 +155,7 @@ export function assembleAuthoringContributions(
151
155
  const field = {} as Record<string, unknown>;
152
156
  const type = {} as Record<string, unknown>;
153
157
  const entityTypes = {} as Record<string, unknown>;
158
+ const pslBlockDescriptors: Record<string, unknown> = {};
154
159
 
155
160
  for (const descriptor of descriptors) {
156
161
  if (descriptor.authoring?.field) {
@@ -180,17 +185,36 @@ export function assembleAuthoringContributions(
180
185
  'entity',
181
186
  );
182
187
  }
188
+ if (descriptor.authoring?.pslBlockDescriptors) {
189
+ mergeAuthoringNamespaces(
190
+ pslBlockDescriptors,
191
+ descriptor.authoring.pslBlockDescriptors,
192
+ [],
193
+ isAuthoringPslBlockDescriptor,
194
+ 'pslBlock',
195
+ );
196
+ }
183
197
  }
184
198
 
185
199
  const fieldNamespace = field as AuthoringFieldNamespace;
186
200
  const typeNamespace = type as AuthoringTypeNamespace;
187
201
  const entityTypeNamespace = entityTypes as AuthoringEntityTypeNamespace;
188
- assertNoCrossRegistryCollisions(typeNamespace, fieldNamespace, entityTypeNamespace);
202
+ const pslBlockDescriptorNamespace = blindCast<
203
+ AuthoringPslBlockDescriptorNamespace,
204
+ 'merge target accumulator narrows to typed namespace post-merge'
205
+ >(pslBlockDescriptors);
206
+ assertNoCrossRegistryCollisions(
207
+ typeNamespace,
208
+ fieldNamespace,
209
+ entityTypeNamespace,
210
+ pslBlockDescriptorNamespace,
211
+ );
189
212
 
190
213
  return {
191
214
  field: fieldNamespace,
192
215
  type: typeNamespace,
193
216
  entityTypes: entityTypeNamespace,
217
+ pslBlockDescriptors: pslBlockDescriptorNamespace,
194
218
  };
195
219
  }
196
220
 
@@ -345,12 +369,107 @@ export function validateScalarTypeCodecIds(
345
369
  return errors;
346
370
  }
347
371
 
372
+ interface DependencyDeclaringDescriptor {
373
+ readonly id: string;
374
+ readonly contractSpace?: {
375
+ readonly contractJson?: {
376
+ readonly extensionPacks?: Readonly<Record<string, unknown>>;
377
+ };
378
+ };
379
+ }
380
+
381
+ function readDeclaredDependencyIds(descriptor: DependencyDeclaringDescriptor): readonly string[] {
382
+ const packs = descriptor.contractSpace?.contractJson?.extensionPacks;
383
+ if (packs === null || typeof packs !== 'object') return [];
384
+ return Object.keys(packs);
385
+ }
386
+
387
+ /**
388
+ * Builds a dependency-respecting load order for the given extension descriptors
389
+ * using Kahn's topological sort algorithm. Dependencies (packs declared in
390
+ * `contractSpace.contractJson.extensionPacks`) are placed before the extensions
391
+ * that depend on them.
392
+ *
393
+ * Throws if the dependency graph contains a cycle, with an error message that
394
+ * names every extension involved in the cycle.
395
+ *
396
+ * Throws if any extension declares a dependency on a pack ID that is not present
397
+ * in the provided list — add the missing pack to the `extensionPacks` list to
398
+ * resolve the error.
399
+ */
400
+
401
+ export function buildExtensionLoadOrder(
402
+ extensions: ReadonlyArray<DependencyDeclaringDescriptor>,
403
+ ): readonly string[] {
404
+ if (extensions.length === 0) return [];
405
+
406
+ const idSet = new Set(extensions.map((e) => e.id));
407
+ const inDegree = new Map<string, number>();
408
+ const dependents = new Map<string, string[]>();
409
+
410
+ for (const ext of extensions) {
411
+ if (!inDegree.has(ext.id)) inDegree.set(ext.id, 0);
412
+ if (!dependents.has(ext.id)) dependents.set(ext.id, []);
413
+ }
414
+
415
+ for (const ext of extensions) {
416
+ for (const depId of readDeclaredDependencyIds(ext)) {
417
+ if (!idSet.has(depId)) {
418
+ throw new Error(
419
+ `Extension "${ext.id}" declares a dependency on "${depId}", but "${depId}" is not in the provided extension set. Add the missing space to extensionPacks.`,
420
+ );
421
+ }
422
+ inDegree.set(ext.id, (inDegree.get(ext.id) ?? 0) + 1);
423
+ const list = dependents.get(depId);
424
+ if (list !== undefined) list.push(ext.id);
425
+ }
426
+ }
427
+
428
+ const queue: string[] = [];
429
+ for (const [id, deg] of inDegree) {
430
+ if (deg === 0) queue.push(id);
431
+ }
432
+ queue.sort();
433
+
434
+ const result: string[] = [];
435
+ while (queue.length > 0) {
436
+ const id = queue.shift();
437
+ if (id === undefined) break;
438
+ result.push(id);
439
+ const children = dependents.get(id) ?? [];
440
+ children.sort();
441
+ for (const childId of children) {
442
+ const newDeg = (inDegree.get(childId) ?? 1) - 1;
443
+ inDegree.set(childId, newDeg);
444
+ if (newDeg === 0) queue.push(childId);
445
+ }
446
+ }
447
+
448
+ if (result.length < extensions.length) {
449
+ const cycleMembers = extensions
450
+ .map((e) => e.id)
451
+ .filter((id) => !result.includes(id))
452
+ .sort();
453
+ throw new Error(
454
+ `Extension dependency cycle detected. Cycle members: ${cycleMembers.map((id) => `"${id}"`).join(', ')}.`,
455
+ );
456
+ }
457
+
458
+ return result;
459
+ }
460
+
348
461
  export function createControlStack<TFamilyId extends string, TTargetId extends string>(
349
462
  input: CreateControlStackInput<TFamilyId, TTargetId>,
350
463
  ): ControlStack<TFamilyId, TTargetId> {
351
464
  const { family, target, adapter, driver, extensionPacks = [] } = input;
352
465
 
353
- const allDescriptors = [family, target, ...(adapter ? [adapter] : []), ...extensionPacks];
466
+ const orderedIds = buildExtensionLoadOrder(extensionPacks);
467
+ const extensionById = new Map(extensionPacks.map((ext) => [ext.id, ext]));
468
+ const orderedExtensionPacks = orderedIds
469
+ .map((id) => extensionById.get(id))
470
+ .filter((ext): ext is ControlExtensionDescriptor<TFamilyId, TTargetId> => ext !== undefined);
471
+
472
+ const allDescriptors = [family, target, ...(adapter ? [adapter] : []), ...orderedExtensionPacks];
354
473
 
355
474
  const codecLookup = extractCodecLookup(allDescriptors);
356
475
  const scalarTypeDescriptors = assembleScalarTypeDescriptors(allDescriptors);
@@ -360,11 +479,11 @@ export function createControlStack<TFamilyId extends string, TTargetId extends s
360
479
  target,
361
480
  adapter,
362
481
  driver,
363
- extensionPacks: extensionPacks as readonly ControlExtensionDescriptor<TFamilyId, TTargetId>[],
482
+ extensionPacks: orderedExtensionPacks,
364
483
 
365
484
  codecTypeImports: extractCodecTypeImports(allDescriptors),
366
485
  queryOperationTypeImports: extractQueryOperationTypeImports(allDescriptors),
367
- extensionIds: extractComponentIds(family, target, adapter, extensionPacks),
486
+ extensionIds: extractComponentIds(family, target, adapter, orderedExtensionPacks),
368
487
  codecLookup,
369
488
  authoringContributions: assembleAuthoringContributions(allDescriptors),
370
489
  scalarTypeDescriptors,
@@ -1,28 +1,25 @@
1
- export interface PslPosition {
2
- readonly offset: number;
3
- readonly line: number;
4
- readonly column: number;
5
- }
6
-
7
- export interface PslSpan {
8
- readonly start: PslPosition;
9
- readonly end: PslPosition;
10
- }
11
-
12
- export type PslDiagnosticCode =
13
- | 'PSL_UNTERMINATED_BLOCK'
14
- | 'PSL_UNSUPPORTED_TOP_LEVEL_BLOCK'
15
- | 'PSL_INVALID_NAMESPACE_BLOCK'
16
- | 'PSL_INVALID_ATTRIBUTE_SYNTAX'
17
- | 'PSL_INVALID_MODEL_MEMBER'
18
- | 'PSL_UNSUPPORTED_MODEL_ATTRIBUTE'
19
- | 'PSL_UNSUPPORTED_FIELD_ATTRIBUTE'
20
- | 'PSL_INVALID_RELATION_ATTRIBUTE'
21
- | 'PSL_INVALID_REFERENTIAL_ACTION'
22
- | 'PSL_INVALID_DEFAULT_VALUE'
23
- | 'PSL_INVALID_ENUM_MEMBER'
24
- | 'PSL_INVALID_TYPES_MEMBER'
25
- | 'PSL_INVALID_QUALIFIED_TYPE';
1
+ export type { AuthoringPslBlockDescriptorNamespace } from '../shared/framework-authoring';
2
+ export type {
3
+ PslBlockParam,
4
+ PslBlockParamList,
5
+ PslBlockParamOption,
6
+ PslBlockParamRef,
7
+ PslBlockParamValue,
8
+ PslDiagnosticCode,
9
+ PslExtensionBlock,
10
+ PslExtensionBlockParamList,
11
+ PslExtensionBlockParamOption,
12
+ PslExtensionBlockParamRef,
13
+ PslExtensionBlockParamScalarValue,
14
+ PslExtensionBlockParamValue,
15
+ PslPosition,
16
+ PslSpan,
17
+ } from '../shared/psl-extension-block';
18
+
19
+ import { blindCast } from '@prisma-next/utils/casts';
20
+ import type { CodecLookup } from '../shared/codec-types';
21
+ import type { AuthoringPslBlockDescriptorNamespace } from '../shared/framework-authoring';
22
+ import type { PslDiagnosticCode, PslExtensionBlock, PslSpan } from '../shared/psl-extension-block';
26
23
 
27
24
  export interface PslDiagnostic {
28
25
  readonly code: PslDiagnosticCode;
@@ -82,10 +79,19 @@ export type PslFieldAttribute = PslAttribute;
82
79
  export interface PslField {
83
80
  readonly kind: 'field';
84
81
  readonly name: string;
85
- /** Unqualified type name, e.g. `"User"` for both `User` and `auth.User`. */
82
+ /** Unqualified type name, e.g. `"User"` for both `User`, `auth.User`, and `supabase:auth.User`. */
86
83
  readonly typeName: string;
87
- /** Namespace qualifier from a dot-qualified type reference, e.g. `"auth"` for `auth.User`. Absent for unqualified types. */
84
+ /** Namespace qualifier from a dot-qualified type reference, e.g. `"auth"` for `auth.User` or `supabase:auth.User`. Absent for unqualified types. */
88
85
  readonly typeNamespaceId?: string;
86
+ /**
87
+ * Contract-space qualifier from a colon-prefix type reference, e.g. `"supabase"` for
88
+ * `supabase:auth.User` or `supabase:User`. Absent for local (same-space) type references.
89
+ *
90
+ * When present, the field references a model from a different contract space. The namespace
91
+ * (`typeNamespaceId`) and model name (`typeName`) identify the target within that space.
92
+ * Physical table resolution against the extension contract is deferred to the aggregate stage (M3).
93
+ */
94
+ readonly typeContractSpaceId?: string;
89
95
  readonly typeConstructor?: PslTypeConstructorCall;
90
96
  readonly optional: boolean;
91
97
  readonly list: boolean;
@@ -148,6 +154,11 @@ export interface PslEnum {
148
154
  readonly span: PslSpan;
149
155
  }
150
156
 
157
+ /**
158
+ * A reusable group of fields embedded in a model (a `type Name { … }` block) —
159
+ * e.g. a MongoDB embedded document or a Postgres composite type. Unlike
160
+ * {@link PslModel} it has no storage or identity of its own.
161
+ */
151
162
  export interface PslCompositeType {
152
163
  readonly kind: 'compositeType';
153
164
  readonly name: string;
@@ -190,22 +201,135 @@ export interface PslTypesBlock {
190
201
  */
191
202
  export const UNSPECIFIED_PSL_NAMESPACE_ID = '__unspecified__';
192
203
 
204
+ /** A value in {@link PslNamespace.entries}: a built-in entity node or an extension-contributed {@link PslExtensionBlock}. */
205
+ export type PslNamespaceEntry = PslModel | PslEnum | PslCompositeType | PslExtensionBlock;
206
+
193
207
  /**
194
- * A named namespace block from a PSL document, or the parser's synthesised
195
- * `__unspecified__` bucket for declarations that appear outside any
196
- * `namespace { … }` block. Multiple `namespace foo { … }` blocks for the
197
- * same name across one or more files reopen-merge into a single entry;
208
+ * A namespace block, or the parser's synthesised `__unspecified__` bucket for
209
+ * declarations outside any `namespace { }`. Same-name blocks reopen-merge;
198
210
  * `span` points at the first opening.
211
+ *
212
+ * Entities are stored canonically (ADR 224) in `entries[kind][name]`, where
213
+ * `kind` is the PSL keyword for built-ins or the block discriminator for
214
+ * extension kinds, e.g. `entries['policy_select']['ReadPosts']`.
199
215
  */
200
216
  export interface PslNamespace {
201
217
  readonly kind: 'namespace';
202
218
  readonly name: string;
219
+ /** Canonical store: a frozen container of frozen per-kind maps. The accessors below derive from it. */
220
+ readonly entries: Readonly<Record<string, Readonly<Record<string, PslNamespaceEntry>>>>;
221
+ /** Built-in models, from `entries['model']`. Extension kinds: {@link namespacePslExtensionBlocks}. */
203
222
  readonly models: readonly PslModel[];
223
+ /** Built-in enums, from `entries['enum']`. */
204
224
  readonly enums: readonly PslEnum[];
225
+ /** Built-in composite types, from `entries['compositeType']`. */
205
226
  readonly compositeTypes: readonly PslCompositeType[];
206
227
  readonly span: PslSpan;
207
228
  }
208
229
 
230
+ /**
231
+ * Stores `entries`; exposes `models`/`enums`/`compositeTypes` as getters over
232
+ * it. The getters are prototype members (non-enumerable), so spreading or
233
+ * `JSON.stringify`-ing a namespace copies only `entries`, never a duplicate view.
234
+ */
235
+ class PslNamespaceNode implements PslNamespace {
236
+ readonly kind = 'namespace' as const;
237
+ readonly name: string;
238
+ readonly entries: Readonly<Record<string, Readonly<Record<string, PslNamespaceEntry>>>>;
239
+ readonly span: PslSpan;
240
+
241
+ constructor(init: {
242
+ readonly name: string;
243
+ readonly entries: Readonly<Record<string, Readonly<Record<string, PslNamespaceEntry>>>>;
244
+ readonly span: PslSpan;
245
+ }) {
246
+ this.name = init.name;
247
+ this.entries = init.entries;
248
+ this.span = init.span;
249
+ Object.freeze(this);
250
+ }
251
+
252
+ get models(): readonly PslModel[] {
253
+ return blindCast<readonly PslModel[], 'entries[model] holds only PslModel by construction'>(
254
+ Object.values(this.entries['model'] ?? {}),
255
+ );
256
+ }
257
+
258
+ get enums(): readonly PslEnum[] {
259
+ return blindCast<readonly PslEnum[], 'entries[enum] holds only PslEnum by construction'>(
260
+ Object.values(this.entries['enum'] ?? {}),
261
+ );
262
+ }
263
+
264
+ get compositeTypes(): readonly PslCompositeType[] {
265
+ return blindCast<
266
+ readonly PslCompositeType[],
267
+ 'entries[compositeType] holds only PslCompositeType by construction'
268
+ >(Object.values(this.entries['compositeType'] ?? {}));
269
+ }
270
+ }
271
+
272
+ /** Constructs a {@link PslNamespace}. Use this, never a namespace literal — the accessors must derive from `entries`. */
273
+ export function makePslNamespace(init: {
274
+ readonly kind: 'namespace';
275
+ readonly name: string;
276
+ readonly entries: Readonly<Record<string, Readonly<Record<string, PslNamespaceEntry>>>>;
277
+ readonly span: PslSpan;
278
+ }): PslNamespace {
279
+ return new PslNamespaceNode(init);
280
+ }
281
+
282
+ /**
283
+ * Builds the frozen `entries[kind][name]` container from per-kind arrays.
284
+ * Built-ins key on their PSL keyword; extension blocks key on their `kind`
285
+ * discriminator. Call this rather than hand-building the literal.
286
+ */
287
+ export function makePslNamespaceEntries(
288
+ models: readonly PslModel[],
289
+ enums: readonly PslEnum[],
290
+ compositeTypes: readonly PslCompositeType[],
291
+ extensionBlocks: readonly PslExtensionBlock[],
292
+ ): Readonly<Record<string, Readonly<Record<string, PslNamespaceEntry>>>> {
293
+ const container: Record<string, Readonly<Record<string, PslNamespaceEntry>>> = {};
294
+
295
+ if (models.length > 0) {
296
+ const map: Record<string, PslModel> = {};
297
+ for (const m of models) {
298
+ map[m.name] = m;
299
+ }
300
+ container['model'] = Object.freeze(map);
301
+ }
302
+
303
+ if (enums.length > 0) {
304
+ const map: Record<string, PslEnum> = {};
305
+ for (const e of enums) {
306
+ map[e.name] = e;
307
+ }
308
+ container['enum'] = Object.freeze(map);
309
+ }
310
+
311
+ if (compositeTypes.length > 0) {
312
+ const map: Record<string, PslCompositeType> = {};
313
+ for (const ct of compositeTypes) {
314
+ map[ct.name] = ct;
315
+ }
316
+ container['compositeType'] = Object.freeze(map);
317
+ }
318
+
319
+ for (const block of extensionBlocks) {
320
+ const existing = container[block.kind];
321
+ const newMap: Record<string, PslExtensionBlock> = existing
322
+ ? blindCast<Record<string, PslExtensionBlock>, 'kind map holds only PslExtensionBlock'>({
323
+ ...existing,
324
+ })
325
+ : {};
326
+ newMap[block.name] = block;
327
+ container[block.kind] = Object.freeze(newMap);
328
+ }
329
+
330
+ return Object.freeze(container);
331
+ }
332
+
209
333
  export interface PslDocumentAst {
210
334
  readonly kind: 'document';
211
335
  readonly sourceId: string;
@@ -219,26 +343,102 @@ export interface PslDocumentAst {
219
343
  * for consumers that don't (yet) need namespace-awareness.
220
344
  */
221
345
  export function flatPslModels(ast: PslDocumentAst): readonly PslModel[] {
222
- return ast.namespaces.flatMap((ns) => ns.models);
346
+ return ast.namespaces.flatMap((ns) =>
347
+ blindCast<PslModel[], 'model kind map contains only PslModel by construction'>(
348
+ Object.values(ns.entries['model'] ?? {}),
349
+ ),
350
+ );
223
351
  }
224
352
 
225
353
  /**
226
354
  * Returns all enums from every namespace in document order.
227
355
  */
228
356
  export function flatPslEnums(ast: PslDocumentAst): readonly PslEnum[] {
229
- return ast.namespaces.flatMap((ns) => ns.enums);
357
+ return ast.namespaces.flatMap((ns) =>
358
+ blindCast<PslEnum[], 'enum kind map contains only PslEnum by construction'>(
359
+ Object.values(ns.entries['enum'] ?? {}),
360
+ ),
361
+ );
230
362
  }
231
363
 
232
364
  /**
233
365
  * Returns all composite types from every namespace in document order.
234
366
  */
235
367
  export function flatPslCompositeTypes(ast: PslDocumentAst): readonly PslCompositeType[] {
236
- return ast.namespaces.flatMap((ns) => ns.compositeTypes);
368
+ return ast.namespaces.flatMap((ns) =>
369
+ blindCast<
370
+ PslCompositeType[],
371
+ 'compositeType kind map contains only PslCompositeType by construction'
372
+ >(Object.values(ns.entries['compositeType'] ?? {})),
373
+ );
374
+ }
375
+
376
+ /**
377
+ * The set of `entries` kind keys that the framework parser reserves for
378
+ * built-in PSL entity kinds. Any own-enumerable key on `PslNamespace.entries`
379
+ * that is **not** in this set was contributed by an extension-block descriptor.
380
+ *
381
+ * Built-in keys match the PSL keyword used on each block type:
382
+ * `'model'`, `'enum'`, `'compositeType'`.
383
+ */
384
+ export const BUILTIN_PSL_KIND_KEYS: ReadonlySet<string> = new Set([
385
+ 'model',
386
+ 'enum',
387
+ 'compositeType',
388
+ ]);
389
+
390
+ /**
391
+ * Returns all extension-contributed blocks in the given namespace, in
392
+ * insertion order (the order the parser encountered them in the source).
393
+ *
394
+ * Reads from `namespace.entries`, skipping the three built-in kind keys
395
+ * (`'model'`, `'enum'`, `'compositeType'`). All remaining kind maps contain
396
+ * only `PslExtensionBlock` nodes by construction (see `makePslNamespaceEntries`).
397
+ */
398
+ export function namespacePslExtensionBlocks(ns: PslNamespace): readonly PslExtensionBlock[] {
399
+ const result: PslExtensionBlock[] = [];
400
+ for (const [kindKey, kindMap] of Object.entries(ns.entries)) {
401
+ if (BUILTIN_PSL_KIND_KEYS.has(kindKey)) continue;
402
+ for (const entry of Object.values(kindMap)) {
403
+ result.push(
404
+ blindCast<
405
+ PslExtensionBlock,
406
+ 'non-builtin kind maps contain only PslExtensionBlock by construction'
407
+ >(entry),
408
+ );
409
+ }
410
+ }
411
+ return result;
237
412
  }
238
413
 
239
414
  export interface ParsePslDocumentInput {
240
415
  readonly schema: string;
241
416
  readonly sourceId: string;
417
+ /**
418
+ * Registry of declarative block descriptors, keyed by arbitrary path
419
+ * segments with {@link AuthoringPslBlockDescriptor} leaves. The registry
420
+ * teaches the parser which top-level keywords belong to extension
421
+ * contributions: when the parser encounters an unknown keyword, it looks
422
+ * it up here and, when found, reads the block generically into a
423
+ * {@link PslExtensionBlock} node. Absent or undefined means no extension
424
+ * blocks are registered and any unknown keyword yields
425
+ * `PSL_UNSUPPORTED_TOP_LEVEL_BLOCK`.
426
+ *
427
+ * Contrast with the parsed block nodes themselves, which live in
428
+ * {@link PslNamespace.entries} under their discriminator key (read them with
429
+ * {@link namespacePslExtensionBlocks}); this field holds the registry of
430
+ * descriptors that teach the parser how to read those blocks.
431
+ */
432
+ readonly pslBlockDescriptors?: AuthoringPslBlockDescriptorNamespace;
433
+ /**
434
+ * Codec lookup for validating `value`-kind extension block parameters.
435
+ * When provided alongside `pslBlockDescriptors`, the generic validator runs
436
+ * over every parsed extension block after the full AST is assembled,
437
+ * appending any diagnostics to the parse result. Absent or undefined means
438
+ * no codec validation runs; `ref` resolution still runs when namespace
439
+ * context is available (built from the assembled namespaces).
440
+ */
441
+ readonly codecLookup?: CodecLookup;
242
442
  }
243
443
 
244
444
  export interface ParsePslDocumentResult {