@prisma-next/framework-components 0.12.0-dev.61 → 0.12.0-dev.62
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.
- package/dist/authoring.d.mts +2 -2
- package/dist/authoring.mjs +2 -2
- package/dist/components.d.mts +1 -1
- package/dist/control.d.mts +4 -3
- package/dist/control.d.mts.map +1 -1
- package/dist/control.mjs +8 -3
- package/dist/control.mjs.map +1 -1
- package/dist/execution.d.mts +1 -1
- package/dist/{framework-authoring-Szvddbl3.mjs → framework-authoring-CnwPJCO4.mjs} +76 -5
- package/dist/framework-authoring-CnwPJCO4.mjs.map +1 -0
- package/dist/framework-authoring-Cyde8zSN.d.mts +380 -0
- package/dist/framework-authoring-Cyde8zSN.d.mts.map +1 -0
- package/dist/{framework-components-Ce_Cdw76.d.mts → framework-components-DdqvMc8S.d.mts} +2 -2
- package/dist/{framework-components-Ce_Cdw76.d.mts.map → framework-components-DdqvMc8S.d.mts.map} +1 -1
- package/dist/{psl-ast-CTuBYLYj.d.mts → psl-ast-DRzRF9rS.d.mts} +46 -12
- package/dist/psl-ast-DRzRF9rS.d.mts.map +1 -0
- package/dist/psl-ast.d.mts +37 -2
- package/dist/psl-ast.d.mts.map +1 -0
- package/dist/psl-ast.mjs +142 -1
- package/dist/psl-ast.mjs.map +1 -1
- package/package.json +7 -7
- package/src/control/control-stack.ts +25 -1
- package/src/control/psl-ast.ts +62 -25
- package/src/control/psl-extension-block-validator.ts +340 -0
- package/src/exports/authoring.ts +16 -0
- package/src/exports/psl-ast.ts +2 -0
- package/src/shared/framework-authoring.ts +215 -2
- package/src/shared/psl-extension-block.ts +184 -0
- package/dist/framework-authoring-Cv04iZjB.d.mts +0 -183
- package/dist/framework-authoring-Cv04iZjB.d.mts.map +0 -1
- package/dist/framework-authoring-Szvddbl3.mjs.map +0 -1
- package/dist/psl-ast-CTuBYLYj.d.mts.map +0 -1
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic validator for extension-contributed top-level PSL blocks.
|
|
3
|
+
*
|
|
4
|
+
* One function — {@link validateExtensionBlock} — takes a parsed
|
|
5
|
+
* {@link PslExtensionBlock}, its {@link AuthoringPslBlockDescriptor}, a
|
|
6
|
+
* {@link CodecLookup} (for `value` parameters), and the set of
|
|
7
|
+
* {@link PslNamespace} objects from the document (for `ref` resolution), and
|
|
8
|
+
* returns the full list of {@link PslDiagnostic} objects for the block.
|
|
9
|
+
*
|
|
10
|
+
* Detection logic per failure mode:
|
|
11
|
+
*
|
|
12
|
+
* 1. **Unknown parameter** — keys present in `node.parameters` that are absent
|
|
13
|
+
* from `descriptor.parameters` (key-set difference). The parser stores
|
|
14
|
+
* unknown parameters as `kind:'value'` stubs; the validator discovers them
|
|
15
|
+
* by comparing the key sets, not by inspecting the captured kind.
|
|
16
|
+
*
|
|
17
|
+
* 2. **Missing required parameter** — `descriptor.parameters` entries with
|
|
18
|
+
* `required: true` whose key is absent from `node.parameters`.
|
|
19
|
+
*
|
|
20
|
+
* 3. **`option` value outside its set** — the captured `token` is not in
|
|
21
|
+
* `descriptor.values`.
|
|
22
|
+
*
|
|
23
|
+
* 4. **`value` rejected by its codec** — the raw string is first parsed as
|
|
24
|
+
* JSON (`JSON.parse(raw)`). If `JSON.parse` throws, the literal is not valid
|
|
25
|
+
* JSON and a `PSL_EXTENSION_INVALID_VALUE` diagnostic is emitted. If parsing
|
|
26
|
+
* succeeds but `codec.decodeJson(jsonValue)` throws, the JSON value is not
|
|
27
|
+
* acceptable to the codec and a `PSL_EXTENSION_INVALID_VALUE` diagnostic is
|
|
28
|
+
* emitted. If `codecLookup.get(codecId)` returns `undefined` (unknown codec
|
|
29
|
+
* id), a `PSL_EXTENSION_INVALID_VALUE` diagnostic is also emitted.
|
|
30
|
+
*
|
|
31
|
+
* 5. **`ref` that does not resolve within its scope** — the captured
|
|
32
|
+
* `identifier` is looked up in the PSL document's `PslNamespace` objects
|
|
33
|
+
* according to `param.scope`:
|
|
34
|
+
* - `same-namespace`: the referent must be in the same namespace as the
|
|
35
|
+
* block (the namespace containing the block).
|
|
36
|
+
* - `same-space`: the referent may be in any namespace in the document.
|
|
37
|
+
* - `cross-space`: pass-through — enforcement is scoped to first-consumer
|
|
38
|
+
* need (RLS roles). This case is documented and clearly flagged; the
|
|
39
|
+
* caller is responsible for wiring cross-space resolution when needed.
|
|
40
|
+
*
|
|
41
|
+
* 6. **`list`** — each element is validated against `param.of` recursively.
|
|
42
|
+
*
|
|
43
|
+
* ### `char`/`varchar` length
|
|
44
|
+
* Not enforced. RLS `using`/`check` strings are unbounded text and the codec
|
|
45
|
+
* already rejects structurally invalid literals; length constraints are a
|
|
46
|
+
* database-side concern, not a PSL authoring constraint.
|
|
47
|
+
*
|
|
48
|
+
* ### `cross-space` scope
|
|
49
|
+
* Implemented as a documented pass-through. The spec permits scoping
|
|
50
|
+
* cross-space enforcement to first-consumer need (RLS roles). When RLS roles
|
|
51
|
+
* arrive, wire `cross-space` resolution through the cross-contract-space
|
|
52
|
+
* coordinate model `(spaceId, namespaceId, entityKind, entityName)`.
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
import type { JsonValue } from '@prisma-next/contract/types';
|
|
56
|
+
import { blindCast } from '@prisma-next/utils/casts';
|
|
57
|
+
import type { CodecLookup } from '../shared/codec-types';
|
|
58
|
+
import type { AuthoringPslBlockDescriptor } from '../shared/framework-authoring';
|
|
59
|
+
import type {
|
|
60
|
+
PslBlockParam,
|
|
61
|
+
PslBlockParamRef,
|
|
62
|
+
PslExtensionBlock,
|
|
63
|
+
PslExtensionBlockParamValue,
|
|
64
|
+
PslSpan,
|
|
65
|
+
} from '../shared/psl-extension-block';
|
|
66
|
+
import type { PslDiagnostic, PslNamespace } from './psl-ast';
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Context for ref resolution during extension-block validation.
|
|
70
|
+
*
|
|
71
|
+
* - `ownerNamespace` is the `PslNamespace` that contains the block being
|
|
72
|
+
* validated. Used for `same-namespace` scope checks.
|
|
73
|
+
* - `allNamespaces` is every namespace in the document. Used for `same-space`
|
|
74
|
+
* scope checks.
|
|
75
|
+
*/
|
|
76
|
+
export interface ExtensionBlockRefResolutionContext {
|
|
77
|
+
readonly ownerNamespace: PslNamespace;
|
|
78
|
+
readonly allNamespaces: readonly PslNamespace[];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Validate a single parsed extension block against its descriptor.
|
|
83
|
+
*
|
|
84
|
+
* Returns an array of {@link PslDiagnostic} objects (possibly empty). The
|
|
85
|
+
* caller is responsible for threading `sourceId` into each returned diagnostic
|
|
86
|
+
* — the returned objects already have `sourceId` set from the `sourceId`
|
|
87
|
+
* parameter.
|
|
88
|
+
*
|
|
89
|
+
* @param node - The parsed block node produced by the generic framework parser.
|
|
90
|
+
* @param descriptor - The descriptor that claims this block's keyword.
|
|
91
|
+
* @param sourceId - The PSL source file identifier (threaded into diagnostics).
|
|
92
|
+
* @param codecLookup - Used to validate `value`-kind parameter literals via
|
|
93
|
+
* `codecLookup.get(codecId)?.decodeJson(JSON.parse(raw))`.
|
|
94
|
+
* @param refCtx - Namespace context for `ref`-kind scope resolution. Required
|
|
95
|
+
* when any descriptor parameter is `kind: 'ref'`; may be omitted if none are.
|
|
96
|
+
*/
|
|
97
|
+
export function validateExtensionBlock(
|
|
98
|
+
node: PslExtensionBlock,
|
|
99
|
+
descriptor: AuthoringPslBlockDescriptor,
|
|
100
|
+
sourceId: string,
|
|
101
|
+
codecLookup: CodecLookup,
|
|
102
|
+
refCtx?: ExtensionBlockRefResolutionContext,
|
|
103
|
+
): readonly PslDiagnostic[] {
|
|
104
|
+
const diagnostics: PslDiagnostic[] = [];
|
|
105
|
+
|
|
106
|
+
const descriptorKeys = new Set(Object.keys(descriptor.parameters));
|
|
107
|
+
const nodeKeys = new Set(Object.keys(node.parameters));
|
|
108
|
+
|
|
109
|
+
// 1. Unknown parameters — keys in the node not in the descriptor.
|
|
110
|
+
for (const key of nodeKeys) {
|
|
111
|
+
if (!descriptorKeys.has(key)) {
|
|
112
|
+
const captured = node.parameters[key];
|
|
113
|
+
diagnostics.push({
|
|
114
|
+
code: 'PSL_EXTENSION_UNKNOWN_PARAMETER',
|
|
115
|
+
message: `Unknown parameter "${key}" in "${descriptor.keyword}" block "${node.name}". The descriptor does not declare this parameter.`,
|
|
116
|
+
sourceId,
|
|
117
|
+
span: captured?.span ?? node.span,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// 2. Missing required parameters — required descriptor keys absent from the node.
|
|
123
|
+
for (const [key, param] of Object.entries(descriptor.parameters)) {
|
|
124
|
+
if (param.required === true && !nodeKeys.has(key)) {
|
|
125
|
+
diagnostics.push({
|
|
126
|
+
code: 'PSL_EXTENSION_MISSING_REQUIRED_PARAMETER',
|
|
127
|
+
message: `Required parameter "${key}" is missing from "${descriptor.keyword}" block "${node.name}".`,
|
|
128
|
+
sourceId,
|
|
129
|
+
span: node.span,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// 3–5. Per-parameter validation for parameters that are present.
|
|
135
|
+
for (const [key, param] of Object.entries(descriptor.parameters)) {
|
|
136
|
+
const captured = node.parameters[key];
|
|
137
|
+
if (captured === undefined) {
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
validateParam(
|
|
141
|
+
node,
|
|
142
|
+
descriptor,
|
|
143
|
+
key,
|
|
144
|
+
param,
|
|
145
|
+
captured,
|
|
146
|
+
sourceId,
|
|
147
|
+
codecLookup,
|
|
148
|
+
refCtx,
|
|
149
|
+
diagnostics,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return diagnostics;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function validateParam(
|
|
157
|
+
node: PslExtensionBlock,
|
|
158
|
+
descriptor: AuthoringPslBlockDescriptor,
|
|
159
|
+
key: string,
|
|
160
|
+
param: PslBlockParam,
|
|
161
|
+
captured: PslExtensionBlockParamValue,
|
|
162
|
+
sourceId: string,
|
|
163
|
+
codecLookup: CodecLookup,
|
|
164
|
+
refCtx: ExtensionBlockRefResolutionContext | undefined,
|
|
165
|
+
diagnostics: PslDiagnostic[],
|
|
166
|
+
): void {
|
|
167
|
+
switch (param.kind) {
|
|
168
|
+
case 'option': {
|
|
169
|
+
if (captured.kind !== 'option') {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (!param.values.includes(captured.token)) {
|
|
173
|
+
diagnostics.push({
|
|
174
|
+
code: 'PSL_EXTENSION_OPTION_OUT_OF_SET',
|
|
175
|
+
message: `Parameter "${key}" in "${descriptor.keyword}" block "${node.name}" has value "${captured.token}" which is not one of the allowed values: ${param.values.map((v) => `"${v}"`).join(', ')}.`,
|
|
176
|
+
sourceId,
|
|
177
|
+
span: captured.span,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
case 'value': {
|
|
184
|
+
if (captured.kind !== 'value') {
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const codec = codecLookup.get(param.codecId);
|
|
188
|
+
if (codec === undefined) {
|
|
189
|
+
diagnostics.push({
|
|
190
|
+
code: 'PSL_EXTENSION_INVALID_VALUE',
|
|
191
|
+
message: `Parameter "${key}" in "${descriptor.keyword}" block "${node.name}" references unknown codec "${param.codecId}".`,
|
|
192
|
+
sourceId,
|
|
193
|
+
span: captured.span,
|
|
194
|
+
});
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
let jsonValue: unknown;
|
|
198
|
+
try {
|
|
199
|
+
jsonValue = JSON.parse(captured.raw);
|
|
200
|
+
} catch {
|
|
201
|
+
diagnostics.push({
|
|
202
|
+
code: 'PSL_EXTENSION_INVALID_VALUE',
|
|
203
|
+
message: `Parameter "${key}" in "${descriptor.keyword}" block "${node.name}" is not a valid JSON literal (expected a JSON string, number, boolean, or null): ${captured.raw}`,
|
|
204
|
+
sourceId,
|
|
205
|
+
span: captured.span,
|
|
206
|
+
});
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
try {
|
|
210
|
+
codec.decodeJson(
|
|
211
|
+
blindCast<JsonValue, 'JSON.parse returns a JsonValue-compatible value'>(jsonValue),
|
|
212
|
+
);
|
|
213
|
+
} catch (err) {
|
|
214
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
215
|
+
diagnostics.push({
|
|
216
|
+
code: 'PSL_EXTENSION_INVALID_VALUE',
|
|
217
|
+
message: `Parameter "${key}" in "${descriptor.keyword}" block "${node.name}" was rejected by codec "${param.codecId}": ${reason}`,
|
|
218
|
+
sourceId,
|
|
219
|
+
span: captured.span,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
case 'ref': {
|
|
226
|
+
if (captured.kind !== 'ref') {
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
validateRef(
|
|
230
|
+
node,
|
|
231
|
+
descriptor,
|
|
232
|
+
key,
|
|
233
|
+
param,
|
|
234
|
+
captured.identifier,
|
|
235
|
+
captured.span,
|
|
236
|
+
sourceId,
|
|
237
|
+
refCtx,
|
|
238
|
+
diagnostics,
|
|
239
|
+
);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
case 'list': {
|
|
244
|
+
if (captured.kind !== 'list') {
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
for (const item of captured.items) {
|
|
248
|
+
validateParam(
|
|
249
|
+
node,
|
|
250
|
+
descriptor,
|
|
251
|
+
key,
|
|
252
|
+
param.of,
|
|
253
|
+
item,
|
|
254
|
+
sourceId,
|
|
255
|
+
codecLookup,
|
|
256
|
+
refCtx,
|
|
257
|
+
diagnostics,
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function validateRef(
|
|
266
|
+
node: PslExtensionBlock,
|
|
267
|
+
descriptor: AuthoringPslBlockDescriptor,
|
|
268
|
+
key: string,
|
|
269
|
+
param: PslBlockParamRef,
|
|
270
|
+
identifier: string,
|
|
271
|
+
span: PslSpan,
|
|
272
|
+
sourceId: string,
|
|
273
|
+
refCtx: ExtensionBlockRefResolutionContext | undefined,
|
|
274
|
+
diagnostics: PslDiagnostic[],
|
|
275
|
+
): void {
|
|
276
|
+
if (param.scope === 'cross-space') {
|
|
277
|
+
// cross-space enforcement is a documented pass-through. The spec permits
|
|
278
|
+
// scoping cross-space resolution to first-consumer need (RLS roles). When
|
|
279
|
+
// that consumer arrives, wire resolution here through the
|
|
280
|
+
// cross-contract-space coordinate model
|
|
281
|
+
// (spaceId, namespaceId, entityKind, entityName).
|
|
282
|
+
// For now, cross-space refs pass validation unconditionally.
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (refCtx === undefined) {
|
|
287
|
+
// If no resolution context was provided, skip ref resolution. This matches
|
|
288
|
+
// the closed-grammar invariant: callers that register ref parameters must
|
|
289
|
+
// provide resolution context; callers without namespaces (e.g. unit tests
|
|
290
|
+
// that only exercise other validation modes) can omit it.
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const namespacesToSearch: readonly PslNamespace[] =
|
|
295
|
+
param.scope === 'same-namespace' ? [refCtx.ownerNamespace] : refCtx.allNamespaces;
|
|
296
|
+
|
|
297
|
+
if (!resolveEntityInNamespaces(identifier, param.refKind, namespacesToSearch)) {
|
|
298
|
+
const scopeLabel =
|
|
299
|
+
param.scope === 'same-namespace' ? 'the same namespace' : 'any namespace in the schema';
|
|
300
|
+
diagnostics.push({
|
|
301
|
+
code: 'PSL_EXTENSION_UNRESOLVED_REF',
|
|
302
|
+
message: `Parameter "${key}" in "${descriptor.keyword}" block "${node.name}" refers to "${identifier}" (expected ${param.refKind}), but no entity with that name and kind was found in ${scopeLabel}.`,
|
|
303
|
+
sourceId,
|
|
304
|
+
span,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Returns true when an entity named `name` of kind `refKind` exists in at
|
|
311
|
+
* least one of the given namespaces.
|
|
312
|
+
*
|
|
313
|
+
* Built-in PSL entity kinds are mapped to their `PslNamespace` collection:
|
|
314
|
+
* - `'model'` → `ns.models`
|
|
315
|
+
* - `'enum'` → `ns.enums`
|
|
316
|
+
* - `'compositeType'` → `ns.compositeTypes`
|
|
317
|
+
*
|
|
318
|
+
* Any other `refKind` is resolved against the namespace's `extensionBlocks`
|
|
319
|
+
* array (matching by `block.kind === refKind` and `block.name === name`).
|
|
320
|
+
* This covers extension-contributed entity kinds that reference other
|
|
321
|
+
* extension-contributed blocks (e.g. a policy referencing a role block).
|
|
322
|
+
*/
|
|
323
|
+
function resolveEntityInNamespaces(
|
|
324
|
+
name: string,
|
|
325
|
+
refKind: string,
|
|
326
|
+
namespaces: readonly PslNamespace[],
|
|
327
|
+
): boolean {
|
|
328
|
+
for (const ns of namespaces) {
|
|
329
|
+
if (refKind === 'model') {
|
|
330
|
+
if (ns.models.some((m) => m.name === name)) return true;
|
|
331
|
+
} else if (refKind === 'enum') {
|
|
332
|
+
if (ns.enums.some((e) => e.name === name)) return true;
|
|
333
|
+
} else if (refKind === 'compositeType') {
|
|
334
|
+
if (ns.compositeTypes.some((ct) => ct.name === name)) return true;
|
|
335
|
+
} else {
|
|
336
|
+
if (ns.extensionBlocks?.some((b) => b.kind === refKind && b.name === name)) return true;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return false;
|
|
340
|
+
}
|
package/src/exports/authoring.ts
CHANGED
|
@@ -11,6 +11,8 @@ export type {
|
|
|
11
11
|
AuthoringFieldNamespace,
|
|
12
12
|
AuthoringFieldPresetDescriptor,
|
|
13
13
|
AuthoringFieldPresetOutput,
|
|
14
|
+
AuthoringPslBlockDescriptor,
|
|
15
|
+
AuthoringPslBlockDescriptorNamespace,
|
|
14
16
|
AuthoringStorageTypeTemplate,
|
|
15
17
|
AuthoringTemplateValue,
|
|
16
18
|
AuthoringTypeConstructorDescriptor,
|
|
@@ -25,8 +27,22 @@ export {
|
|
|
25
27
|
isAuthoringArgRef,
|
|
26
28
|
isAuthoringEntityTypeDescriptor,
|
|
27
29
|
isAuthoringFieldPresetDescriptor,
|
|
30
|
+
isAuthoringPslBlockDescriptor,
|
|
28
31
|
isAuthoringTypeConstructorDescriptor,
|
|
29
32
|
mergeAuthoringNamespaces,
|
|
30
33
|
resolveAuthoringTemplateValue,
|
|
31
34
|
validateAuthoringHelperArguments,
|
|
32
35
|
} from '../shared/framework-authoring';
|
|
36
|
+
export type {
|
|
37
|
+
PslBlockParam,
|
|
38
|
+
PslBlockParamList,
|
|
39
|
+
PslBlockParamOption,
|
|
40
|
+
PslBlockParamRef,
|
|
41
|
+
PslBlockParamValue,
|
|
42
|
+
PslExtensionBlock,
|
|
43
|
+
PslExtensionBlockParamList,
|
|
44
|
+
PslExtensionBlockParamOption,
|
|
45
|
+
PslExtensionBlockParamRef,
|
|
46
|
+
PslExtensionBlockParamScalarValue,
|
|
47
|
+
PslExtensionBlockParamValue,
|
|
48
|
+
} from '../shared/psl-extension-block';
|
package/src/exports/psl-ast.ts
CHANGED
|
@@ -7,8 +7,10 @@ import {
|
|
|
7
7
|
isColumnDefaultLiteralInputValue,
|
|
8
8
|
isExecutionMutationDefaultValue,
|
|
9
9
|
} from '@prisma-next/contract/types';
|
|
10
|
+
import { blindCast } from '@prisma-next/utils/casts';
|
|
10
11
|
import { ifDefined } from '@prisma-next/utils/defined';
|
|
11
12
|
import type { Type } from 'arktype';
|
|
13
|
+
import type { PslBlockParam } from './psl-extension-block';
|
|
12
14
|
|
|
13
15
|
export type AuthoringArgRef = {
|
|
14
16
|
readonly kind: 'arg';
|
|
@@ -157,10 +159,55 @@ export type AuthoringEntityTypeNamespace = {
|
|
|
157
159
|
readonly [name: string]: AuthoringEntityTypeDescriptor | AuthoringEntityTypeNamespace;
|
|
158
160
|
};
|
|
159
161
|
|
|
162
|
+
/**
|
|
163
|
+
* Declarative descriptor for an extension-contributed top-level PSL block.
|
|
164
|
+
*
|
|
165
|
+
* An extension registers one of these per keyword it contributes. The
|
|
166
|
+
* framework owns the generic parser, validator, and printer — no
|
|
167
|
+
* parsing or printing code runs from the extension.
|
|
168
|
+
*
|
|
169
|
+
* - `keyword` is the PSL top-level identifier this descriptor claims
|
|
170
|
+
* (`policy_select`, `role`, …).
|
|
171
|
+
* - `discriminator` is the routing key used by the printer dispatch and
|
|
172
|
+
* the `entityTypes` lowering factory lookup. Convention:
|
|
173
|
+
* `<target-or-family>-<kind>` (`postgres-policy-select`).
|
|
174
|
+
* - `name.required` declares whether the block must have a name token
|
|
175
|
+
* after the keyword. Currently always `true` — anonymous blocks are
|
|
176
|
+
* not part of the closed-grammar premise — but the field is explicit
|
|
177
|
+
* so the type can evolve without a breaking change.
|
|
178
|
+
* - `parameters` maps parameter names to their value-kind descriptors
|
|
179
|
+
* (`ref` / `value` / `option` / `list`). The generic parser and
|
|
180
|
+
* validator interpret these; the extension supplies no parser or
|
|
181
|
+
* printer function.
|
|
182
|
+
*/
|
|
183
|
+
export interface AuthoringPslBlockDescriptor {
|
|
184
|
+
readonly kind: 'pslBlock';
|
|
185
|
+
readonly keyword: string;
|
|
186
|
+
readonly discriminator: string;
|
|
187
|
+
readonly name: { readonly required: boolean };
|
|
188
|
+
readonly parameters: Record<string, PslBlockParam>;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export type AuthoringPslBlockDescriptorNamespace = {
|
|
192
|
+
readonly [name: string]: AuthoringPslBlockDescriptor | AuthoringPslBlockDescriptorNamespace;
|
|
193
|
+
};
|
|
194
|
+
|
|
160
195
|
export interface AuthoringContributions {
|
|
161
196
|
readonly type?: AuthoringTypeNamespace;
|
|
162
197
|
readonly field?: AuthoringFieldNamespace;
|
|
163
198
|
readonly entityTypes?: AuthoringEntityTypeNamespace;
|
|
199
|
+
/**
|
|
200
|
+
* Registry of declarative block descriptors this contribution registers,
|
|
201
|
+
* keyed by arbitrary path segments. Each leaf is an
|
|
202
|
+
* {@link AuthoringPslBlockDescriptor} that claims a PSL top-level keyword.
|
|
203
|
+
* The framework owns the generic parser, validator, and printer; the
|
|
204
|
+
* contribution supplies only these declarative descriptors.
|
|
205
|
+
*
|
|
206
|
+
* Contrast with {@link PslNamespace.extensionBlocks}: that field holds
|
|
207
|
+
* the parsed block nodes in a namespace; this field holds the registry
|
|
208
|
+
* of descriptors that teach the parser how to read those blocks.
|
|
209
|
+
*/
|
|
210
|
+
readonly pslBlockDescriptors?: AuthoringPslBlockDescriptorNamespace;
|
|
164
211
|
}
|
|
165
212
|
|
|
166
213
|
export function isAuthoringArgRef(value: unknown): value is AuthoringArgRef {
|
|
@@ -228,6 +275,42 @@ export function isAuthoringEntityTypeDescriptor(
|
|
|
228
275
|
return typeof factory === 'function' || template !== undefined;
|
|
229
276
|
}
|
|
230
277
|
|
|
278
|
+
export function isAuthoringPslBlockDescriptor(
|
|
279
|
+
value: unknown,
|
|
280
|
+
): value is AuthoringPslBlockDescriptor {
|
|
281
|
+
if (typeof value !== 'object' || value === null) {
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
const record = blindCast<
|
|
285
|
+
Record<string, unknown>,
|
|
286
|
+
'type-guard probing an unknown candidate-descriptor object for known property names'
|
|
287
|
+
>(value);
|
|
288
|
+
if (record['kind'] !== 'pslBlock') {
|
|
289
|
+
return false;
|
|
290
|
+
}
|
|
291
|
+
const keyword = record['keyword'];
|
|
292
|
+
if (typeof keyword !== 'string' || keyword.length === 0) {
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
const discriminator = record['discriminator'];
|
|
296
|
+
if (typeof discriminator !== 'string' || discriminator.length === 0) {
|
|
297
|
+
return false;
|
|
298
|
+
}
|
|
299
|
+
const name = record['name'];
|
|
300
|
+
if (typeof name !== 'object' || name === null) {
|
|
301
|
+
return false;
|
|
302
|
+
}
|
|
303
|
+
const nameRecord = blindCast<
|
|
304
|
+
Record<string, unknown>,
|
|
305
|
+
'type-guard probing the name property of a candidate pslBlock descriptor'
|
|
306
|
+
>(name);
|
|
307
|
+
if (typeof nameRecord['required'] !== 'boolean') {
|
|
308
|
+
return false;
|
|
309
|
+
}
|
|
310
|
+
const parameters = record['parameters'];
|
|
311
|
+
return typeof parameters === 'object' && parameters !== null && !Array.isArray(parameters);
|
|
312
|
+
}
|
|
313
|
+
|
|
231
314
|
/**
|
|
232
315
|
* Returns true when `namespace` is a non-leaf key in `contributions.field`.
|
|
233
316
|
*
|
|
@@ -341,10 +424,127 @@ function collectAuthoringLeafPaths(
|
|
|
341
424
|
return paths;
|
|
342
425
|
}
|
|
343
426
|
|
|
427
|
+
interface AuthoringLeafEntry {
|
|
428
|
+
readonly path: string;
|
|
429
|
+
readonly discriminator: string;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function collectAuthoringLeafDiscriminators(
|
|
433
|
+
namespace: Readonly<Record<string, unknown>>,
|
|
434
|
+
isLeaf: (value: unknown) => boolean,
|
|
435
|
+
label: string,
|
|
436
|
+
path: readonly string[] = [],
|
|
437
|
+
): AuthoringLeafEntry[] {
|
|
438
|
+
const entries: AuthoringLeafEntry[] = [];
|
|
439
|
+
for (const [key, value] of Object.entries(namespace)) {
|
|
440
|
+
const currentPath = [...path, key];
|
|
441
|
+
if (isLeaf(value)) {
|
|
442
|
+
const record = blindCast<
|
|
443
|
+
Record<string, unknown>,
|
|
444
|
+
'discriminator extraction from a leaf already validated by isLeaf'
|
|
445
|
+
>(value);
|
|
446
|
+
const discriminator = record['discriminator'];
|
|
447
|
+
if (typeof discriminator === 'string' && discriminator.length > 0) {
|
|
448
|
+
entries.push({ path: currentPath.join('.'), discriminator });
|
|
449
|
+
}
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
453
|
+
const record = blindCast<
|
|
454
|
+
Readonly<Record<string, unknown>>,
|
|
455
|
+
'walker inspects a non-leaf value for descriptor-shaped keys before recursing'
|
|
456
|
+
>(value);
|
|
457
|
+
// A value carrying descriptor-shaped keys (`kind`/`keyword`/`discriminator`)
|
|
458
|
+
// but failing `isAuthoringPslBlockDescriptor` (e.g. missing `parameters`) is
|
|
459
|
+
// a malformed declarative descriptor. Descending into it as a sub-namespace
|
|
460
|
+
// would silently skip it, so a half-built contribution would pass validation.
|
|
461
|
+
// Reject it at load time instead, naming the path and what's wrong.
|
|
462
|
+
//
|
|
463
|
+
// A valid sub-namespace whose key happens to be named `kind`, `keyword`, or
|
|
464
|
+
// `discriminator` (but which does not look like a descriptor overall) must
|
|
465
|
+
// still descend normally — the check requires descriptor-shaped keys present
|
|
466
|
+
// AND the leaf guard rejecting it.
|
|
467
|
+
if (
|
|
468
|
+
(record['kind'] !== undefined ||
|
|
469
|
+
record['keyword'] !== undefined ||
|
|
470
|
+
record['discriminator'] !== undefined) &&
|
|
471
|
+
!isLeaf(value)
|
|
472
|
+
) {
|
|
473
|
+
const hasKind = record['kind'] === 'pslBlock';
|
|
474
|
+
const hasKeyword = typeof record['keyword'] === 'string';
|
|
475
|
+
const hasDiscriminator = typeof record['discriminator'] === 'string';
|
|
476
|
+
if (hasKind || (hasKeyword && hasDiscriminator)) {
|
|
477
|
+
throw new Error(
|
|
478
|
+
`Malformed authoring ${label} contribution at "${currentPath.join('.')}". The value carries descriptor keys (kind/keyword/discriminator) but does not satisfy the ${label} descriptor shape. Fix the contribution so it is a complete descriptor, or remove the stray keys if it was meant to be a sub-namespace.`,
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
entries.push(...collectAuthoringLeafDiscriminators(record, isLeaf, label, currentPath));
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
return entries;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Throws when two or more entries in the same namespace share a discriminator.
|
|
490
|
+
* Duplicate discriminators within a namespace make dispatch ambiguous — the
|
|
491
|
+
* lowering factory lookup dispatches by discriminator, so one would silently
|
|
492
|
+
* shadow the other. Catch duplicates before building any dispatch map.
|
|
493
|
+
*/
|
|
494
|
+
function assertUniqueDiscriminators(entries: readonly AuthoringLeafEntry[], label: string): void {
|
|
495
|
+
const seen = new Map<string, string>();
|
|
496
|
+
for (const { path, discriminator } of entries) {
|
|
497
|
+
const existing = seen.get(discriminator);
|
|
498
|
+
if (existing !== undefined) {
|
|
499
|
+
throw new Error(
|
|
500
|
+
`Duplicate ${label} discriminator "${discriminator}" registered at both "${existing}" and "${path}". Each ${label} contribution must use a unique discriminator.`,
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
seen.set(discriminator, path);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Every `pslBlockDescriptors` entry needs a matching `entityTypes` factory
|
|
509
|
+
* (same discriminator): the parser would otherwise produce an AST node
|
|
510
|
+
* nothing can lower to an IR class instance. The link is one-directional
|
|
511
|
+
* — an `entityTypes` factory may stand alone (e.g. `enum`, reachable from
|
|
512
|
+
* the TypeScript builder without any PSL block).
|
|
513
|
+
*/
|
|
514
|
+
function assertPslBlocksHaveFactories(
|
|
515
|
+
entityTypeNamespace: AuthoringEntityTypeNamespace,
|
|
516
|
+
pslBlockNamespace: AuthoringPslBlockDescriptorNamespace,
|
|
517
|
+
): void {
|
|
518
|
+
const blockEntries = collectAuthoringLeafDiscriminators(
|
|
519
|
+
pslBlockNamespace,
|
|
520
|
+
isAuthoringPslBlockDescriptor,
|
|
521
|
+
'pslBlock',
|
|
522
|
+
);
|
|
523
|
+
const entityEntries = collectAuthoringLeafDiscriminators(
|
|
524
|
+
entityTypeNamespace,
|
|
525
|
+
isAuthoringEntityTypeDescriptor,
|
|
526
|
+
'entityType',
|
|
527
|
+
);
|
|
528
|
+
|
|
529
|
+
assertUniqueDiscriminators(blockEntries, 'pslBlock');
|
|
530
|
+
assertUniqueDiscriminators(entityEntries, 'entityType');
|
|
531
|
+
|
|
532
|
+
const entityDiscriminators = new Set(entityEntries.map((entry) => entry.discriminator));
|
|
533
|
+
|
|
534
|
+
for (const block of blockEntries) {
|
|
535
|
+
if (!entityDiscriminators.has(block.discriminator)) {
|
|
536
|
+
throw new Error(
|
|
537
|
+
`Incomplete extension contribution: pslBlock helper "${block.path}" registers discriminator "${block.discriminator}" but no entityType contribution shares that discriminator. An extension-contributed PSL block requires a matching entityType factory so the parsed AST node can lower to an IR class instance; add an entityType helper with discriminator "${block.discriminator}".`,
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
344
543
|
export function assertNoCrossRegistryCollisions(
|
|
345
544
|
typeNamespace: AuthoringTypeNamespace,
|
|
346
545
|
fieldNamespace: AuthoringFieldNamespace,
|
|
347
546
|
entityTypeNamespace: AuthoringEntityTypeNamespace = {},
|
|
547
|
+
pslBlockNamespace: AuthoringPslBlockDescriptorNamespace = {},
|
|
348
548
|
): void {
|
|
349
549
|
const typePaths = new Set(
|
|
350
550
|
collectAuthoringLeafPaths(typeNamespace, isAuthoringTypeConstructorDescriptor),
|
|
@@ -360,20 +560,33 @@ export function assertNoCrossRegistryCollisions(
|
|
|
360
560
|
// `mergeHelperNamespaces` in composed-authoring-helpers.ts), which throws
|
|
361
561
|
// on same-path registrations within any single registry before this check
|
|
362
562
|
// runs. This function only handles the cross-registry case.
|
|
563
|
+
//
|
|
564
|
+
// Cross-registry collisions are checked among `type` / `field` /
|
|
565
|
+
// `entityTypes` only — these three are user-facing helper paths that PSL
|
|
566
|
+
// must resolve unambiguously. `pslBlockDescriptors` is an internal
|
|
567
|
+
// framework index consumed by parser and printer dispatch, not a
|
|
568
|
+
// user-facing helper path; the natural authoring pattern is the same
|
|
569
|
+
// path key in `entityTypes` and `pslBlockDescriptors` for a single
|
|
570
|
+
// contribution. The block→factory link is enforced by
|
|
571
|
+
// `assertPslBlocksHaveFactories` via the discriminator string, not by path.
|
|
572
|
+
const ambiguityHint =
|
|
573
|
+
'Register each path in only one of authoringContributions.field / authoringContributions.type / authoringContributions.entityTypes.';
|
|
363
574
|
for (const fieldPath of fieldPaths) {
|
|
364
575
|
if (typePaths.has(fieldPath)) {
|
|
365
576
|
throw new Error(
|
|
366
|
-
`Ambiguous authoring registry path "${fieldPath}". The same path is registered as both a type constructor and a field preset; PSL resolution would be ambiguous.
|
|
577
|
+
`Ambiguous authoring registry path "${fieldPath}". The same path is registered as both a type constructor and a field preset; PSL resolution would be ambiguous. ${ambiguityHint}`,
|
|
367
578
|
);
|
|
368
579
|
}
|
|
369
580
|
}
|
|
370
581
|
for (const entityPath of entityPaths) {
|
|
371
582
|
if (typePaths.has(entityPath) || fieldPaths.has(entityPath)) {
|
|
372
583
|
throw new Error(
|
|
373
|
-
`Ambiguous authoring registry path "${entityPath}". The same path is registered as an entity contribution AND as a type constructor or field preset; PSL resolution would be ambiguous.
|
|
584
|
+
`Ambiguous authoring registry path "${entityPath}". The same path is registered as an entity contribution AND as a type constructor or field preset; PSL resolution would be ambiguous. ${ambiguityHint}`,
|
|
374
585
|
);
|
|
375
586
|
}
|
|
376
587
|
}
|
|
588
|
+
|
|
589
|
+
assertPslBlocksHaveFactories(entityTypeNamespace, pslBlockNamespace);
|
|
377
590
|
}
|
|
378
591
|
|
|
379
592
|
export function resolveAuthoringTemplateValue(
|