@pulse-compute/wasm-schema-json 0.0.0 → 1.0.0-beta.2

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.
@@ -0,0 +1,705 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const ts = require('typescript');
6
+ const {
7
+ normalizeSchemaRegistry,
8
+ codecInputsForRegistry
9
+ } = require('@pulse-compute/wasm-contracts/schema-json/registry');
10
+
11
+ const SCHEMA_REGISTRY_EXTRACTOR_VERSION = 'pulse.schema-registry-extractor.v1';
12
+ const AUTHORING_MODULE = '@pulse-compute/pulse/schema';
13
+ const HELPER_EXPORTS = new Set(['defineSchemaRegistry', 'schema', 'response']);
14
+ const MARKER_EXPORTS = new Map([['Int32', 'i32'], ['Uint32', 'u32']]);
15
+
16
+ class SchemaRegistryExtractionError extends Error {
17
+ constructor(code, message, detail = {}) {
18
+ super(message);
19
+ this.name = 'SchemaRegistryExtractionError';
20
+ this.code = code;
21
+ this.detail = Object.freeze({ ...detail });
22
+ }
23
+ }
24
+
25
+ function fail(code, message, node, record, detail = {}) {
26
+ const start = node && record ? node.getStart(record.sourceFile) : 0;
27
+ const loc = record
28
+ ? record.sourceFile.getLineAndCharacterOfPosition(start)
29
+ : { line: 0, character: 0 };
30
+ throw new SchemaRegistryExtractionError(code, message, {
31
+ ...detail,
32
+ source: record ? {
33
+ file: record.relative,
34
+ line: loc.line + 1,
35
+ column: loc.character + 1
36
+ } : undefined
37
+ });
38
+ }
39
+
40
+ function slash(value) {
41
+ return String(value).replace(/\\/g, '/');
42
+ }
43
+
44
+ function isInside(root, file) {
45
+ const relative = path.relative(root, file);
46
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
47
+ }
48
+
49
+ function sourceLocation(record, node) {
50
+ const start = node.getStart(record.sourceFile);
51
+ const loc = record.sourceFile.getLineAndCharacterOfPosition(start);
52
+ return Object.freeze({
53
+ file: record.relative,
54
+ line: loc.line + 1,
55
+ column: loc.character + 1
56
+ });
57
+ }
58
+
59
+ function isExported(node) {
60
+ return Boolean(node.modifiers && node.modifiers.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword));
61
+ }
62
+
63
+ function staticPropertyName(node, record, options = {}) {
64
+ if (!node) return null;
65
+ if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text;
66
+ if (options.identifiers && ts.isIdentifier(node)) return node.text;
67
+ fail(
68
+ options.code || 'PULSE_SCHEMA_REGISTRY_STATIC_KEY_REQUIRED',
69
+ options.message || 'Schema registry keys must be string literals.',
70
+ node,
71
+ record
72
+ );
73
+ }
74
+
75
+ function unwrapExpression(node) {
76
+ let current = node;
77
+ while (
78
+ current
79
+ && (
80
+ ts.isParenthesizedExpression(current)
81
+ || ts.isAsExpression(current)
82
+ || ts.isTypeAssertionExpression(current)
83
+ || ts.isSatisfiesExpression(current)
84
+ || ts.isNonNullExpression(current)
85
+ )
86
+ ) current = current.expression;
87
+ return current;
88
+ }
89
+
90
+ class TypeGraph {
91
+ constructor(root) {
92
+ this.root = path.resolve(root);
93
+ this.records = new Map();
94
+ this.dependencies = new Set();
95
+ }
96
+
97
+ resolveRelative(fromFile, specifier, node, record) {
98
+ if (!specifier.startsWith('./') && !specifier.startsWith('../') && specifier !== '.' && specifier !== '..') {
99
+ fail(
100
+ 'PULSE_SCHEMA_TYPE_IMPORT_NONRELATIVE',
101
+ `Schema type import ${JSON.stringify(specifier)} must be workspace-relative.`,
102
+ node,
103
+ record,
104
+ { specifier }
105
+ );
106
+ }
107
+ const base = path.resolve(path.dirname(fromFile), specifier);
108
+ const extension = path.extname(base);
109
+ const withoutJs = /\.(?:js|mjs|cjs)$/.test(extension) ? base.slice(0, -extension.length) : base;
110
+ const candidates = extension && !/\.(?:js|mjs|cjs)$/.test(extension)
111
+ ? [base]
112
+ : [
113
+ `${withoutJs}.ts`,
114
+ `${withoutJs}.tsx`,
115
+ `${withoutJs}.d.ts`,
116
+ path.join(base, 'index.ts'),
117
+ path.join(base, 'index.tsx'),
118
+ path.join(base, 'index.d.ts')
119
+ ];
120
+ const resolved = candidates.find((candidate) => fs.existsSync(candidate) && fs.statSync(candidate).isFile());
121
+ if (!resolved) {
122
+ fail('PULSE_SCHEMA_TYPE_IMPORT_NOT_FOUND', `Could not resolve schema type import ${JSON.stringify(specifier)}.`, node, record, {
123
+ specifier
124
+ });
125
+ }
126
+ if (!isInside(this.root, resolved)) {
127
+ fail('PULSE_SCHEMA_TYPE_IMPORT_ESCAPE', `Schema type import ${JSON.stringify(specifier)} escapes the Pulse workspace.`, node, record, {
128
+ specifier,
129
+ resolved
130
+ });
131
+ }
132
+ return resolved;
133
+ }
134
+
135
+ load(file) {
136
+ const absolute = path.resolve(file);
137
+ if (!isInside(this.root, absolute)) {
138
+ throw new SchemaRegistryExtractionError(
139
+ 'PULSE_SCHEMA_POINTER_ESCAPE',
140
+ 'pulse.schema must remain inside the Pulse workspace.',
141
+ { root: this.root, file: absolute }
142
+ );
143
+ }
144
+ if (this.records.has(absolute)) return this.records.get(absolute);
145
+ if (!fs.existsSync(absolute) || !fs.statSync(absolute).isFile()) {
146
+ throw new SchemaRegistryExtractionError(
147
+ 'PULSE_SCHEMA_REGISTRY_NOT_FOUND',
148
+ `Pulse schema registry does not exist: ${absolute}`,
149
+ { file: absolute }
150
+ );
151
+ }
152
+ const text = fs.readFileSync(absolute, 'utf8');
153
+ const sourceFile = ts.createSourceFile(
154
+ absolute,
155
+ text,
156
+ ts.ScriptTarget.Latest,
157
+ true,
158
+ absolute.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS
159
+ );
160
+ const record = {
161
+ file: absolute,
162
+ relative: slash(path.relative(this.root, absolute)),
163
+ text,
164
+ sourceFile,
165
+ declarations: new Map(),
166
+ imports: new Map(),
167
+ helpers: new Map(),
168
+ markers: new Map(),
169
+ reExports: new Map(),
170
+ exportStars: []
171
+ };
172
+ this.records.set(absolute, record);
173
+ this.dependencies.add(absolute);
174
+ const parseError = (sourceFile.parseDiagnostics || []).find((entry) => entry.category === ts.DiagnosticCategory.Error);
175
+ if (parseError) {
176
+ const start = typeof parseError.start === 'number' ? parseError.start : 0;
177
+ const loc = sourceFile.getLineAndCharacterOfPosition(start);
178
+ throw new SchemaRegistryExtractionError(
179
+ 'PULSE_SCHEMA_REGISTRY_PARSE_FAILED',
180
+ ts.flattenDiagnosticMessageText(parseError.messageText, '\n'),
181
+ { source: { file: record.relative, line: loc.line + 1, column: loc.character + 1 } }
182
+ );
183
+ }
184
+ this.index(record);
185
+ return record;
186
+ }
187
+
188
+ index(record) {
189
+ for (const statement of record.sourceFile.statements) {
190
+ if (ts.isInterfaceDeclaration(statement) || ts.isTypeAliasDeclaration(statement)) {
191
+ record.declarations.set(statement.name.text, {
192
+ node: statement,
193
+ exported: isExported(statement)
194
+ });
195
+ }
196
+ if (ts.isImportDeclaration(statement)) this.indexImport(record, statement);
197
+ if (ts.isExportDeclaration(statement) && statement.moduleSpecifier) this.indexReExport(record, statement);
198
+ }
199
+ }
200
+
201
+ indexImport(record, statement) {
202
+ const specifier = statement.moduleSpecifier.text;
203
+ if (specifier === 'json-as' || specifier.startsWith('json-as/')) {
204
+ fail(
205
+ 'PULSE_SCHEMA_PUBLIC_JSON_AS_IMPORT_FORBIDDEN',
206
+ 'Application schema modules must not import json-as; Pulse owns that internal backend.',
207
+ statement.moduleSpecifier,
208
+ record,
209
+ { specifier }
210
+ );
211
+ }
212
+ const clause = statement.importClause;
213
+ if (!clause) {
214
+ fail('PULSE_SCHEMA_REGISTRY_SIDE_EFFECT_IMPORT_UNSUPPORTED', 'Schema registry graphs do not allow side-effect imports.', statement, record, {
215
+ specifier
216
+ });
217
+ }
218
+ if (specifier === AUTHORING_MODULE) {
219
+ if (clause.name || !clause.namedBindings || !ts.isNamedImports(clause.namedBindings)) {
220
+ fail(
221
+ 'PULSE_SCHEMA_AUTHORING_IMPORT_INVALID',
222
+ `Import named schema helpers and marker types from ${AUTHORING_MODULE}.`,
223
+ statement,
224
+ record
225
+ );
226
+ }
227
+ for (const element of clause.namedBindings.elements) {
228
+ const imported = element.propertyName ? element.propertyName.text : element.name.text;
229
+ const local = element.name.text;
230
+ if (HELPER_EXPORTS.has(imported)) {
231
+ if (clause.isTypeOnly || element.isTypeOnly) {
232
+ fail('PULSE_SCHEMA_HELPER_VALUE_IMPORT_REQUIRED', `${imported} must be imported as a value.`, element, record, { imported });
233
+ }
234
+ record.helpers.set(local, imported);
235
+ } else if (MARKER_EXPORTS.has(imported)) {
236
+ if (!clause.isTypeOnly && !element.isTypeOnly) {
237
+ fail('PULSE_SCHEMA_MARKER_TYPE_IMPORT_REQUIRED', `${imported} must be imported with import type.`, element, record, {
238
+ imported
239
+ });
240
+ }
241
+ record.markers.set(local, MARKER_EXPORTS.get(imported));
242
+ } else {
243
+ fail('PULSE_SCHEMA_AUTHORING_IMPORT_UNKNOWN', `Unsupported schema authoring import ${imported}.`, element, record, {
244
+ imported
245
+ });
246
+ }
247
+ }
248
+ return;
249
+ }
250
+ if (!clause.isTypeOnly && !(clause.namedBindings && ts.isNamedImports(clause.namedBindings)
251
+ && clause.namedBindings.elements.every((element) => element.isTypeOnly))) {
252
+ fail(
253
+ 'PULSE_SCHEMA_REGISTRY_VALUE_IMPORT_UNSUPPORTED',
254
+ 'Schema registry graphs allow only Pulse schema helpers and relative type-only imports.',
255
+ statement,
256
+ record,
257
+ { specifier }
258
+ );
259
+ }
260
+ if (clause.name || !clause.namedBindings || !ts.isNamedImports(clause.namedBindings)) {
261
+ fail('PULSE_SCHEMA_TYPE_IMPORT_NAMED_REQUIRED', 'Schema type imports must use named import type syntax.', statement, record, {
262
+ specifier
263
+ });
264
+ }
265
+ const resolved = this.resolveRelative(record.file, specifier, statement.moduleSpecifier, record);
266
+ this.load(resolved);
267
+ for (const element of clause.namedBindings.elements) {
268
+ record.imports.set(element.name.text, {
269
+ file: resolved,
270
+ name: element.propertyName ? element.propertyName.text : element.name.text
271
+ });
272
+ }
273
+ }
274
+
275
+ indexReExport(record, statement) {
276
+ const namedTypeOnly = statement.exportClause
277
+ && ts.isNamedExports(statement.exportClause)
278
+ && statement.exportClause.elements.length > 0
279
+ && statement.exportClause.elements.every((element) => element.isTypeOnly);
280
+ if (statement.isTypeOnly !== true && !namedTypeOnly) {
281
+ fail(
282
+ 'PULSE_SCHEMA_REGISTRY_VALUE_REEXPORT_UNSUPPORTED',
283
+ 'Schema registry graphs allow only type-only re-exports.',
284
+ statement,
285
+ record
286
+ );
287
+ }
288
+ const resolved = this.resolveRelative(record.file, statement.moduleSpecifier.text, statement.moduleSpecifier, record);
289
+ this.load(resolved);
290
+ if (!statement.exportClause) {
291
+ record.exportStars.push(resolved);
292
+ return;
293
+ }
294
+ if (!ts.isNamedExports(statement.exportClause)) {
295
+ fail('PULSE_SCHEMA_TYPE_REEXPORT_NAMED_REQUIRED', 'Schema type re-exports must be named.', statement, record);
296
+ }
297
+ for (const element of statement.exportClause.elements) {
298
+ const exported = element.name.text;
299
+ const imported = element.propertyName ? element.propertyName.text : element.name.text;
300
+ record.reExports.set(exported, { file: resolved, name: imported });
301
+ }
302
+ }
303
+
304
+ resolve(record, name, requireExport = false, seen = new Set()) {
305
+ const key = `${record.file}#${name}#${requireExport}`;
306
+ if (seen.has(key)) {
307
+ fail('PULSE_SCHEMA_TYPE_REFERENCE_CYCLE', `Cyclic schema type export while resolving ${name}.`, record.sourceFile, record, {
308
+ typeName: name
309
+ });
310
+ }
311
+ seen.add(key);
312
+ const local = record.declarations.get(name);
313
+ if (local && (!requireExport || local.exported)) return { record, declaration: local.node };
314
+ const imported = record.imports.get(name);
315
+ if (imported) return this.resolve(this.load(imported.file), imported.name, true, seen);
316
+ const reExport = record.reExports.get(name);
317
+ if (reExport) return this.resolve(this.load(reExport.file), reExport.name, true, seen);
318
+ for (const file of record.exportStars) {
319
+ const resolved = this.resolve(this.load(file), name, true, new Set(seen));
320
+ if (resolved) return resolved;
321
+ }
322
+ return undefined;
323
+ }
324
+ }
325
+
326
+ function classifyType(graph, record, typeNode, state) {
327
+ if (!typeNode) fail('PULSE_SCHEMA_FIELD_TYPE_REQUIRED', 'Every schema field requires an explicit type.', state.node, record);
328
+ if (ts.isParenthesizedTypeNode(typeNode)) return classifyType(graph, record, typeNode.type, state);
329
+ if (typeNode.kind === ts.SyntaxKind.StringKeyword) return Object.freeze({ kind: 'string' });
330
+ if (typeNode.kind === ts.SyntaxKind.BooleanKeyword) return Object.freeze({ kind: 'boolean' });
331
+ if (typeNode.kind === ts.SyntaxKind.NumberKeyword) return Object.freeze({ kind: 'f64' });
332
+ if (typeNode.kind === ts.SyntaxKind.UndefinedKeyword || typeNode.kind === ts.SyntaxKind.VoidKeyword) {
333
+ fail('PULSE_SCHEMA_OPTIONAL_FIELD_RESERVED', 'undefined and optional schema fields are reserved beyond IR v1.', typeNode, record);
334
+ }
335
+ if (ts.isArrayTypeNode(typeNode)) {
336
+ return Object.freeze({ kind: 'array', element: classifyType(graph, record, typeNode.elementType, state) });
337
+ }
338
+ if (ts.isTypeLiteralNode(typeNode)) return classifyMembers(graph, record, typeNode.members, state);
339
+ if (ts.isUnionTypeNode(typeNode)) {
340
+ const isNullType = (part) => part.kind === ts.SyntaxKind.NullKeyword
341
+ || (ts.isLiteralTypeNode(part) && part.literal.kind === ts.SyntaxKind.NullKeyword);
342
+ const nonNull = typeNode.types.filter((part) => !isNullType(part));
343
+ const nullCount = typeNode.types.length - nonNull.length;
344
+ if (nullCount === 1 && nonNull.length === 1) {
345
+ return Object.freeze({ kind: 'nullable', value: classifyType(graph, record, nonNull[0], state) });
346
+ }
347
+ if (nullCount === 0 && typeNode.types.every((part) => ts.isLiteralTypeNode(part) && ts.isStringLiteral(part.literal))) {
348
+ return Object.freeze({
349
+ kind: 'string-enum',
350
+ values: Object.freeze(typeNode.types.map((part) => part.literal.text))
351
+ });
352
+ }
353
+ fail(
354
+ 'PULSE_SCHEMA_UNION_UNSUPPORTED',
355
+ 'Schema IR v1 supports only string-literal enums or one supported type unioned with null.',
356
+ typeNode,
357
+ record
358
+ );
359
+ }
360
+ if (ts.isLiteralTypeNode(typeNode)) {
361
+ if (ts.isStringLiteral(typeNode.literal)) {
362
+ return Object.freeze({ kind: 'string-enum', values: Object.freeze([typeNode.literal.text]) });
363
+ }
364
+ fail('PULSE_SCHEMA_LITERAL_TYPE_UNSUPPORTED', 'Only string literal schema types are supported.', typeNode, record);
365
+ }
366
+ if (ts.isTypeReferenceNode(typeNode) && ts.isIdentifier(typeNode.typeName)) {
367
+ const name = typeNode.typeName.text;
368
+ if (name === 'Array') {
369
+ if (!typeNode.typeArguments || typeNode.typeArguments.length !== 1) {
370
+ fail('PULSE_SCHEMA_ARRAY_ELEMENT_REQUIRED', 'Array<T> requires exactly one schema element type.', typeNode, record);
371
+ }
372
+ return Object.freeze({ kind: 'array', element: classifyType(graph, record, typeNode.typeArguments[0], state) });
373
+ }
374
+ const marker = record.markers.get(name);
375
+ if (marker) {
376
+ if (typeNode.typeArguments && typeNode.typeArguments.length > 0) {
377
+ fail('PULSE_SCHEMA_MARKER_GENERIC_UNSUPPORTED', `${name} does not accept type arguments.`, typeNode, record);
378
+ }
379
+ return Object.freeze({ kind: marker });
380
+ }
381
+ if (typeNode.typeArguments && typeNode.typeArguments.length > 0) {
382
+ fail('PULSE_SCHEMA_GENERIC_TYPE_UNSUPPORTED', `Generic schema type ${name} is not supported in IR v1.`, typeNode, record, {
383
+ typeName: name
384
+ });
385
+ }
386
+ const resolved = graph.resolve(record, name);
387
+ if (!resolved) {
388
+ fail('PULSE_SCHEMA_TYPE_NOT_FOUND', `Could not resolve schema type ${name}.`, typeNode, record, { typeName: name });
389
+ }
390
+ const key = `${resolved.record.file}#${resolved.declaration.name.text}`;
391
+ if (state.stack.has(key)) {
392
+ fail('PULSE_SCHEMA_RECURSIVE_TYPE_RESERVED', `Recursive schema type ${name} is reserved beyond IR v1.`, typeNode, record, {
393
+ typeName: name
394
+ });
395
+ }
396
+ const next = { ...state, stack: new Set([...state.stack, key]) };
397
+ if (ts.isInterfaceDeclaration(resolved.declaration)) {
398
+ if (resolved.declaration.typeParameters || resolved.declaration.heritageClauses) {
399
+ fail('PULSE_SCHEMA_INHERITANCE_UNSUPPORTED', `Schema interface ${name} must not be generic or extend another type.`, resolved.declaration, resolved.record);
400
+ }
401
+ return classifyMembers(graph, resolved.record, resolved.declaration.members, next);
402
+ }
403
+ if (resolved.declaration.typeParameters) {
404
+ fail('PULSE_SCHEMA_GENERIC_TYPE_UNSUPPORTED', `Generic schema type ${name} is not supported in IR v1.`, resolved.declaration, resolved.record);
405
+ }
406
+ return classifyType(graph, resolved.record, resolved.declaration.type, next);
407
+ }
408
+ fail(
409
+ 'PULSE_SCHEMA_TYPE_UNSUPPORTED',
410
+ `Unsupported schema type syntax ${JSON.stringify(typeNode.getText(record.sourceFile))}.`,
411
+ typeNode,
412
+ record
413
+ );
414
+ }
415
+
416
+ function classifyMembers(graph, record, members, state) {
417
+ const names = new Set();
418
+ const fields = [];
419
+ for (const member of members) {
420
+ if (!ts.isPropertySignature(member)) {
421
+ fail(
422
+ 'PULSE_SCHEMA_OBJECT_MEMBER_UNSUPPORTED',
423
+ 'Schema object types allow required property signatures only.',
424
+ member,
425
+ record
426
+ );
427
+ }
428
+ if (member.questionToken) {
429
+ fail(
430
+ 'PULSE_SCHEMA_OPTIONAL_FIELD_RESERVED',
431
+ 'Optional schema fields are reserved until absence-preserving encode semantics are defined.',
432
+ member,
433
+ record
434
+ );
435
+ }
436
+ const name = staticPropertyName(member.name, record, {
437
+ identifiers: true,
438
+ code: 'PULSE_SCHEMA_FIELD_NAME_STATIC_REQUIRED',
439
+ message: 'Schema field names must be identifiers or string literals.'
440
+ });
441
+ if (names.has(name)) {
442
+ fail('PULSE_SCHEMA_FIELD_DUPLICATE', `Schema object declares field ${name} more than once.`, member.name, record, { name });
443
+ }
444
+ names.add(name);
445
+ fields.push(Object.freeze({
446
+ name,
447
+ required: true,
448
+ value: classifyType(graph, record, member.type, { ...state, node: member }),
449
+ source: sourceLocation(record, member.name)
450
+ }));
451
+ }
452
+ return Object.freeze({ kind: 'object', fields: Object.freeze(fields) });
453
+ }
454
+
455
+ function objectProperties(expression, record, allowed, field, options = {}) {
456
+ const value = unwrapExpression(expression);
457
+ if (!ts.isObjectLiteralExpression(value)) {
458
+ fail('PULSE_SCHEMA_REGISTRY_OBJECT_LITERAL_REQUIRED', `${field} must be an object literal.`, value, record, { field });
459
+ }
460
+ const out = new Map();
461
+ for (const property of value.properties) {
462
+ if (!ts.isPropertyAssignment(property)) {
463
+ fail(
464
+ 'PULSE_SCHEMA_REGISTRY_STATIC_PROPERTY_REQUIRED',
465
+ `${field} does not allow spreads, methods, getters, setters, or shorthand properties.`,
466
+ property,
467
+ record,
468
+ { field }
469
+ );
470
+ }
471
+ const key = staticPropertyName(property.name, record, {
472
+ identifiers: true,
473
+ code: options.keyCode || 'PULSE_SCHEMA_REGISTRY_STATIC_PROPERTY_REQUIRED',
474
+ message: options.keyMessage || `${field} property names must be static.`
475
+ });
476
+ if (allowed && !allowed.has(key)) {
477
+ fail('PULSE_SCHEMA_REGISTRY_PROPERTY_UNSUPPORTED', `${field} contains unsupported property ${key}.`, property.name, record, {
478
+ field,
479
+ key
480
+ });
481
+ }
482
+ if (out.has(key)) {
483
+ fail(options.duplicateCode || 'PULSE_SCHEMA_REGISTRY_PROPERTY_DUPLICATE', `${field} contains duplicate property ${key}.`, property.name, record, {
484
+ field,
485
+ key
486
+ });
487
+ }
488
+ out.set(key, property);
489
+ }
490
+ return { expression: value, properties: out };
491
+ }
492
+
493
+ function helperCall(expression, record, helperName) {
494
+ const value = unwrapExpression(expression);
495
+ if (!ts.isCallExpression(value) || !ts.isIdentifier(value.expression) || record.helpers.get(value.expression.text) !== helperName) {
496
+ fail(
497
+ 'PULSE_SCHEMA_REGISTRY_HELPER_REQUIRED',
498
+ `Schema registry declaration must call ${helperName} imported from ${AUTHORING_MODULE}.`,
499
+ value,
500
+ record,
501
+ { helper: helperName }
502
+ );
503
+ }
504
+ return value;
505
+ }
506
+
507
+ function extractSchemas(graph, record, property) {
508
+ const object = objectProperties(property.initializer, record, null, 'schemas', {
509
+ duplicateCode: 'PULSE_SCHEMA_ID_DUPLICATE',
510
+ keyCode: 'PULSE_SCHEMA_REGISTRY_STATIC_KEY_REQUIRED',
511
+ keyMessage: 'Schema IDs must be fixed string-literal keys.'
512
+ });
513
+ const schemas = [];
514
+ const ids = new Set();
515
+ for (const declaration of object.expression.properties) {
516
+ const id = staticPropertyName(declaration.name, record);
517
+ if (ids.has(id)) fail('PULSE_SCHEMA_ID_DUPLICATE', `Schema ID ${id} is declared more than once.`, declaration.name, record, { id });
518
+ ids.add(id);
519
+ const call = helperCall(declaration.initializer, record, 'schema');
520
+ if (call.arguments.length !== 0 || !call.typeArguments || call.typeArguments.length !== 1) {
521
+ fail(
522
+ 'PULSE_SCHEMA_DECLARATION_SIGNATURE_INVALID',
523
+ `Schema ${id} must use schema<Type>() with one type argument and no runtime arguments.`,
524
+ call,
525
+ record,
526
+ { id }
527
+ );
528
+ }
529
+ const typeNode = call.typeArguments[0];
530
+ const typeReferenceName = ts.isTypeReferenceNode(typeNode) && ts.isIdentifier(typeNode.typeName)
531
+ ? typeNode.typeName.text
532
+ : null;
533
+ const resolvedRoot = typeReferenceName ? graph.resolve(record, typeReferenceName) : null;
534
+ const typeName = resolvedRoot
535
+ ? resolvedRoot.declaration.name.text
536
+ : `Inline_${id.replace(/[^A-Za-z0-9_$]+/g, '_')}`;
537
+ const root = classifyType(graph, record, typeNode, {
538
+ stack: new Set(),
539
+ node: typeNode
540
+ });
541
+ schemas.push(Object.freeze({
542
+ id,
543
+ typeName,
544
+ root,
545
+ source: resolvedRoot
546
+ ? sourceLocation(resolvedRoot.record, resolvedRoot.declaration.name)
547
+ : sourceLocation(record, declaration.name)
548
+ }));
549
+ }
550
+ return schemas;
551
+ }
552
+
553
+ function extractResponses(record, property) {
554
+ if (!property) return [];
555
+ const object = objectProperties(property.initializer, record, null, 'responses', {
556
+ duplicateCode: 'PULSE_RESPONSE_CASE_ID_DUPLICATE',
557
+ keyCode: 'PULSE_RESPONSE_CASE_STATIC_KEY_REQUIRED',
558
+ keyMessage: 'Response case IDs must be fixed string-literal keys.'
559
+ });
560
+ const responses = [];
561
+ const ids = new Set();
562
+ for (const declaration of object.expression.properties) {
563
+ const id = staticPropertyName(declaration.name, record);
564
+ if (ids.has(id)) {
565
+ fail('PULSE_RESPONSE_CASE_ID_DUPLICATE', `Response case ID ${id} is declared more than once.`, declaration.name, record, { id });
566
+ }
567
+ ids.add(id);
568
+ const call = helperCall(declaration.initializer, record, 'response');
569
+ if (call.typeArguments && call.typeArguments.length > 0 || call.arguments.length !== 2) {
570
+ fail(
571
+ 'PULSE_RESPONSE_CASE_SIGNATURE_INVALID',
572
+ `Response case ${id} must use response(status, 'schema.id').`,
573
+ call,
574
+ record,
575
+ { id }
576
+ );
577
+ }
578
+ const statusNode = unwrapExpression(call.arguments[0]);
579
+ const schemaNode = unwrapExpression(call.arguments[1]);
580
+ if (!ts.isNumericLiteral(statusNode) || !Number.isSafeInteger(Number(statusNode.text))) {
581
+ fail('PULSE_RESPONSE_STATUS_LITERAL_REQUIRED', `Response case ${id} requires a static integer status.`, statusNode, record, { id });
582
+ }
583
+ if (!ts.isStringLiteral(schemaNode) && !ts.isNoSubstitutionTemplateLiteral(schemaNode)) {
584
+ fail('PULSE_RESPONSE_SCHEMA_LITERAL_REQUIRED', `Response case ${id} requires a string-literal schema ID.`, schemaNode, record, {
585
+ id
586
+ });
587
+ }
588
+ responses.push(Object.freeze({
589
+ id,
590
+ status: Number(statusNode.text),
591
+ schemaId: schemaNode.text,
592
+ source: sourceLocation(record, declaration.name)
593
+ }));
594
+ }
595
+ return responses;
596
+ }
597
+
598
+ function findDefaultExport(record) {
599
+ const exports = record.sourceFile.statements.filter((statement) => ts.isExportAssignment(statement) && !statement.isExportEquals);
600
+ if (exports.length !== 1) {
601
+ fail(
602
+ 'PULSE_SCHEMA_REGISTRY_DEFAULT_EXPORT_REQUIRED',
603
+ 'pulse.schema must contain exactly one explicit default registry export.',
604
+ exports[1] || record.sourceFile,
605
+ record,
606
+ { count: exports.length }
607
+ );
608
+ }
609
+ return exports[0];
610
+ }
611
+
612
+ function legacyFieldType(node) {
613
+ if (node.kind === 'string') return 'string';
614
+ if (node.kind === 'boolean') return 'bool';
615
+ if (node.kind === 'i32' || node.kind === 'u32' || node.kind === 'f64') return node.kind;
616
+ return null;
617
+ }
618
+
619
+ function schemaRegistryLegacyBridge(registry) {
620
+ const compatible = [];
621
+ const deferred = [];
622
+ for (const schema of registry.schemas) {
623
+ const fieldPairs = schema.root.fields.map((field) => [field.name, legacyFieldType(field.value)]);
624
+ if (schema.typeName.startsWith('Inline_') || fieldPairs.some(([, type]) => type === null)) {
625
+ deferred.push(schema.id);
626
+ continue;
627
+ }
628
+ const separator = schema.id.lastIndexOf('.');
629
+ compatible.push(Object.freeze({
630
+ id: schema.id,
631
+ namespace: schema.id.slice(0, separator),
632
+ name: schema.id.slice(separator + 1),
633
+ type: schema.typeName,
634
+ fields: Object.freeze(Object.fromEntries(fieldPairs))
635
+ }));
636
+ }
637
+ return Object.freeze({
638
+ version: 'pulse.schema-registry-legacy-codec-bridge.v1',
639
+ authority: 'pulse.schema',
640
+ fullCodecRealization: false,
641
+ compatibleIds: Object.freeze(compatible.map((entry) => entry.id)),
642
+ deferredIds: Object.freeze(deferred),
643
+ entries: Object.freeze(compatible)
644
+ });
645
+ }
646
+
647
+ function extractSchemaRegistry(schemaFile, options = {}) {
648
+ const projectRoot = path.resolve(options.projectRoot || path.dirname(path.resolve(schemaFile)));
649
+ const graph = new TypeGraph(projectRoot);
650
+ const record = graph.load(schemaFile);
651
+ const defaultExport = findDefaultExport(record);
652
+ const registryCall = helperCall(defaultExport.expression, record, 'defineSchemaRegistry');
653
+ if ((registryCall.typeArguments && registryCall.typeArguments.length > 0) || registryCall.arguments.length !== 1) {
654
+ fail(
655
+ 'PULSE_SCHEMA_REGISTRY_SIGNATURE_INVALID',
656
+ 'defineSchemaRegistry requires one static registry object and no explicit type arguments.',
657
+ registryCall,
658
+ record
659
+ );
660
+ }
661
+ const registryObject = objectProperties(
662
+ registryCall.arguments[0],
663
+ record,
664
+ new Set(['schemas', 'responses']),
665
+ 'defineSchemaRegistry'
666
+ );
667
+ const schemasProperty = registryObject.properties.get('schemas');
668
+ if (!schemasProperty) {
669
+ fail('PULSE_SCHEMA_REGISTRY_SCHEMAS_REQUIRED', 'defineSchemaRegistry requires a schemas object.', registryObject.expression, record);
670
+ }
671
+ const schemas = extractSchemas(graph, record, schemasProperty);
672
+ const responses = extractResponses(record, registryObject.properties.get('responses'));
673
+ let registry;
674
+ try {
675
+ registry = normalizeSchemaRegistry({
676
+ source: sourceLocation(record, defaultExport),
677
+ schemas,
678
+ responses
679
+ });
680
+ } catch (error) {
681
+ if (error && error.code) {
682
+ throw new SchemaRegistryExtractionError(error.code, error.message, error.details || {});
683
+ }
684
+ throw error;
685
+ }
686
+ const codecInputs = codecInputsForRegistry(registry);
687
+ const legacyBridge = schemaRegistryLegacyBridge(registry);
688
+ return Object.freeze({
689
+ version: SCHEMA_REGISTRY_EXTRACTOR_VERSION,
690
+ registry,
691
+ codecInputs,
692
+ legacyBridge,
693
+ dependencies: Object.freeze([...graph.dependencies].map((file) => path.resolve(file)).sort())
694
+ });
695
+ }
696
+
697
+ module.exports = Object.freeze({
698
+ SCHEMA_REGISTRY_EXTRACTOR_VERSION,
699
+ AUTHORING_MODULE,
700
+ HELPER_EXPORTS,
701
+ MARKER_EXPORTS,
702
+ SchemaRegistryExtractionError,
703
+ extractSchemaRegistry,
704
+ schemaRegistryLegacyBridge
705
+ });