backend-skeleton 1.0.0 → 1.1.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.
Files changed (48) hide show
  1. package/README.md +66 -4
  2. package/bin/bskel.mjs +125 -18
  3. package/contracts/export.mjs +39 -4
  4. package/contracts/openapi.mjs +292 -27
  5. package/contracts/validate.mjs +23 -4
  6. package/handles/_engine.mjs +75 -32
  7. package/handles/capability-codec.mjs +94 -0
  8. package/handles/codec.mjs +13 -3
  9. package/handles/providers/java-spring/emit.mjs +78 -33
  10. package/handles/providers/java-spring/observe.mjs +4 -3
  11. package/handles/providers/java-spring/plan.mjs +51 -7
  12. package/handles/providers/java-spring/templates/HandleCodec.java.tmpl +19 -1
  13. package/handles/providers/java-spring/templates/HandleController.java.tmpl +13 -7
  14. package/handles/providers/java-spring/templates/HandleService.java.tmpl +21 -2
  15. package/handles/providers/java-spring/templates/RecordHandleSnapshot.java.tmpl +1 -1
  16. package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +24 -3
  17. package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +8 -2
  18. package/handles/providers/java-spring.mjs +8 -0
  19. package/handles/providers/python-fastapi/emit.mjs +21 -26
  20. package/handles/providers/python-fastapi/observe.mjs +6 -5
  21. package/handles/providers/python-fastapi/templates/codec.py.tmpl +18 -3
  22. package/handles/providers/python-fastapi/templates/record_snapshot.py.tmpl +32 -4
  23. package/handles/providers/python-fastapi.mjs +3 -3
  24. package/handles/providers/typescript-express/emit.mjs +135 -46
  25. package/handles/providers/typescript-express/observe.mjs +7 -6
  26. package/handles/providers/typescript-express/templates/codec.ts.tmpl +13 -3
  27. package/handles/providers/typescript-express/templates/handleEntities.ts.tmpl +89 -0
  28. package/handles/providers/typescript-express/templates/handleService.ts.tmpl +81 -0
  29. package/handles/providers/typescript-express/templates/migration.sql.tmpl +36 -0
  30. package/handles/providers/typescript-express/templates/recordSnapshotWrapper.ts.tmpl +123 -0
  31. package/handles/providers/typescript-express/templates/registry.ts.tmpl +19 -10
  32. package/handles/providers/typescript-express/templates/resolver.ts.tmpl +13 -0
  33. package/handles/providers/typescript-express/templates/resolverPolicy.ts.tmpl +20 -0
  34. package/handles/providers/typescript-express/templates/router.ts.tmpl +113 -2
  35. package/handles/providers/typescript-express.mjs +7 -4
  36. package/lib/cli.mjs +11 -2
  37. package/lib/exit-codes.mjs +21 -0
  38. package/lib/verify.mjs +23 -6
  39. package/package.json +5 -2
  40. package/scanners/adapters/_java-spring-analyzer.mjs +9 -1
  41. package/scanners/adapters/java-spring.mjs +108 -10
  42. package/scanners/adapters/javascript-express.mjs +46 -13
  43. package/scanners/adapters/typescript-express.mjs +13 -2
  44. package/schemas/feature-contract.schema.json +3 -3
  45. package/schemas/handles-plan.schema.json +2 -0
  46. package/schemas/oracle-manifest.schema.json +58 -0
  47. package/schemas/stack-record.schema.json +6 -1
  48. package/stack/apply.mjs +47 -6
@@ -2,11 +2,31 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { emitUnits } from '../../_engine.mjs';
5
+ import { sha256File } from '../../../lib/fsutil.mjs';
6
+ import { specPath } from '../../../lib/paths.mjs';
7
+ import { loadFeatureFile } from '../../../lib/featurelifecycle.mjs';
5
8
 
6
9
  const PROVIDER_ROOT = path.dirname(fileURLToPath(import.meta.url));
7
10
  const TEMPLATES_DIR = path.join(PROVIDER_ROOT, 'templates');
8
11
  const RESOLVER_TEMPLATE = path.join(TEMPLATES_DIR, 'resolver.ts.tmpl');
12
+ const RESOLVER_POLICY_TEMPLATE = path.join(TEMPLATES_DIR, 'resolverPolicy.ts.tmpl');
9
13
  const RESOLVERS_INDEX_TEMPLATE = path.join(TEMPLATES_DIR, 'resolvers_index.ts.tmpl');
14
+ const MIGRATION_TEMPLATE = path.join(TEMPLATES_DIR, 'migration.sql.tmpl');
15
+
16
+ // D-typescript-express-registry-parity: a resource's fetch route file is the closest analog this
17
+ // provider has to java-spring's/python-fastapi's own "service file" -- there is no separate
18
+ // service layer this scanner extracts, so the static registration-gap check (mirrors Phase 1 item
19
+ // 2 exactly) reads the same file `FETCH_ROUTE_FILE` already points at. Checks for an IMPORT of
20
+ // recordSnapshotWrapper.ts, not the string "recordSnapshot(" alone -- that string is also the name
21
+ // of handleService.ts's own lower-level persistence function (a real, deliberate naming overlap
22
+ // recordSnapshotWrapper.ts.tmpl's own header explains), so a bare substring match would produce
23
+ // false negatives on files that only import the OTHER recordSnapshot.
24
+ const RECORD_SNAPSHOT_WRAPPER_IMPORT_RE = /from\s+['"][^'"]*recordSnapshotWrapper['"]/;
25
+
26
+ function hasRecordSnapshotWrapper(filePath) {
27
+ if (!filePath || !fs.existsSync(filePath)) return false;
28
+ return RECORD_SNAPSHOT_WRAPPER_IMPORT_RE.test(fs.readFileSync(filePath, 'utf8'));
29
+ }
10
30
 
11
31
  function render(templatePath, vars) {
12
32
  let content = fs.readFileSync(templatePath, 'utf8');
@@ -34,53 +54,90 @@ function relativeImportPath(fromFile, toFile) {
34
54
  return rel;
35
55
  }
36
56
 
37
- // G5 (D-typescript-express-provider): mirrors python-fastapi/emit.mjs's own 1st-slice shape
38
- // exactly (as it existed at 627c214, before that provider's own separate recover()/snapshot
39
- // follow-up) -- no migration, no recover(), see the EXCLUDED-equivalent reasoning in
40
- // D-typescript-express-provider. Unlike Python, router.ts is infra emitted UNCONDITIONALLY (no
41
- // SessionDep-shaped precondition exists for it -- TypeORM's DataSource is imported directly by
42
- // each resolver, never injected per-request the way FastAPI's Depends()/SQLAlchemy Session is).
43
- export function emitTypeScriptExpress({ repoRoot, featureId, plan, resourceFilter = null, force = false, reason = '', dryRun = false, computeDiff = false }) {
57
+ // G5 (D-typescript-express-provider), registry parity added in D-typescript-express-registry-parity:
58
+ // migration.sql/recover()/enforcement now mirror java-spring's/python-fastapi's own shape --
59
+ // see that DECISIONS.md entry for the full design (notably: no decorator-based interception
60
+ // mechanism exists in TypeScript the way Java has AOP and Python has decorators, so
61
+ // recordSnapshotWrapper.ts is a higher-order function instead). Unlike Python, router.ts is infra
62
+ // emitted UNCONDITIONALLY (no SessionDep-shaped precondition exists for it -- TypeORM's
63
+ // DataSource is imported directly by each resolver, never injected per-request the way FastAPI's
64
+ // Depends()/SQLAlchemy Session is).
65
+ export function emitTypeScriptExpress({ repoRoot, featureId, plan, resourceFilter = null, force = false, reason = '', dryRun = false, computeDiff = false, enforceRegistry = false }) {
44
66
  const handlesDir = path.join(plan.srcRoot, 'handles');
45
67
  const resolversDir = path.join(handlesDir, 'resolvers');
46
68
  const resolversIndexPath = path.join(resolversDir, 'resolvers_index.ts');
69
+ // D-typescript-express-registry-parity: repo-relative (specs/<id>/handles/migration.sql), not
70
+ // under plan.srcRoot -- mirrors java-spring's/python-fastapi's own migrationPath exactly.
71
+ const migrationPath = path.join(repoRoot, 'specs', featureId, 'handles', 'migration.sql');
47
72
 
48
73
  const infraUnits = [
49
74
  { id: 'codec.ts.tmpl', templatePath: path.join(TEMPLATES_DIR, 'codec.ts.tmpl'), targetAbs: path.join(handlesDir, 'codec.ts'), rendered: render(path.join(TEMPLATES_DIR, 'codec.ts.tmpl'), {}) },
50
75
  { id: 'registry.ts.tmpl', templatePath: path.join(TEMPLATES_DIR, 'registry.ts.tmpl'), targetAbs: path.join(handlesDir, 'registry.ts'), rendered: render(path.join(TEMPLATES_DIR, 'registry.ts.tmpl'), {}) },
51
- { id: 'router.ts.tmpl', templatePath: path.join(TEMPLATES_DIR, 'router.ts.tmpl'), targetAbs: path.join(handlesDir, 'router.ts'), rendered: render(path.join(TEMPLATES_DIR, 'router.ts.tmpl'), {}) },
76
+ { id: 'router.ts.tmpl', templatePath: path.join(TEMPLATES_DIR, 'router.ts.tmpl'), targetAbs: path.join(handlesDir, 'router.ts'), rendered: render(path.join(TEMPLATES_DIR, 'router.ts.tmpl'), { ENFORCE_REGISTRY: enforceRegistry ? 'true' : 'false' }) },
77
+ // D-typescript-express-registry-parity: repo-wide (one registry table, one service module,
78
+ // one wrapper), mirroring java-spring's global/handle/* infra exactly -- not per-resource.
79
+ { id: 'handleEntities.ts.tmpl', templatePath: path.join(TEMPLATES_DIR, 'handleEntities.ts.tmpl'), targetAbs: path.join(handlesDir, 'handleEntities.ts'), rendered: render(path.join(TEMPLATES_DIR, 'handleEntities.ts.tmpl'), {}) },
80
+ { id: 'handleService.ts.tmpl', templatePath: path.join(TEMPLATES_DIR, 'handleService.ts.tmpl'), targetAbs: path.join(handlesDir, 'handleService.ts'), rendered: render(path.join(TEMPLATES_DIR, 'handleService.ts.tmpl'), {}) },
81
+ { id: 'recordSnapshotWrapper.ts.tmpl', templatePath: path.join(TEMPLATES_DIR, 'recordSnapshotWrapper.ts.tmpl'), targetAbs: path.join(handlesDir, 'recordSnapshotWrapper.ts'), rendered: render(path.join(TEMPLATES_DIR, 'recordSnapshotWrapper.ts.tmpl'), {}) },
52
82
  ];
53
83
 
54
- const resolverUnits = plan.resources
55
- .filter((r) => r.willGenerateResolver)
56
- .map((resource) => {
57
- const targetAbs = path.join(resolversDir, `${camelCase(resource.type)}.ts`);
58
- const vars = {
59
- FEATURE_ID: featureId,
60
- RESOURCE_TYPE: resource.type,
61
- MODEL: resource.type,
62
- MODEL_IMPORT_PATH: relativeImportPath(targetAbs, path.join(plan.srcRoot, `${resource.modelImport}.ts`)),
63
- DATA_SOURCE_NAME: resource.dataSource.name,
64
- DATA_SOURCE_IMPORT_PATH: relativeImportPath(targetAbs, resource.dataSource.file),
65
- ID_FIELD: resource.idField,
66
- SELECT_PROJECTION: resource.selectFields.map((f) => `${f}: row.${f}`).join(', '),
67
- FETCH_ROUTE_FILE: resource.fetchRoute ? path.relative(repoRoot, resource.fetchRoute.file) : '(unknown)',
68
- FETCH_ROUTE_LINE: resource.fetchRoute ? resource.fetchRoute.line : '',
69
- };
70
- return {
84
+ // D-resolver-policy-split, ported here in D-typescript-express-registry-parity: mirrors
85
+ // java-spring's/python-fastapi's own contractRefFor/featureUidFor exactly, including the
86
+ // cross-feature adoption-safety fix -- see D-resolver-policy-split in DECISIONS.md.
87
+ const contractRefFor = (id) => sha256File(specPath(repoRoot, id, 'contracts', `${id}.schema.json`));
88
+ const featureUidFor = (id) => loadFeatureFile(repoRoot, id)?.feature_uid ?? '00000000-0000-0000-0000-000000000000';
89
+ const contractRef = contractRefFor(featureId);
90
+ const featureUid = featureUidFor(featureId);
91
+
92
+ const willGenerate = plan.resources.filter((r) => r.willGenerateResolver);
93
+ const resolverUnits = willGenerate.flatMap((resource) => {
94
+ const targetAbs = path.join(resolversDir, `${camelCase(resource.type)}.ts`);
95
+ const policyTargetAbs = path.join(resolversDir, `${camelCase(resource.type)}Policy.ts`);
96
+ const vars = {
97
+ FEATURE_ID: featureId,
98
+ RESOURCE_TYPE: resource.type,
99
+ RESOURCE_TYPE_CAMEL: camelCase(resource.type),
100
+ MODEL: resource.type,
101
+ MODEL_IMPORT_PATH: relativeImportPath(targetAbs, path.join(plan.srcRoot, `${resource.modelImport}.ts`)),
102
+ DATA_SOURCE_NAME: resource.dataSource.name,
103
+ DATA_SOURCE_IMPORT_PATH: relativeImportPath(targetAbs, resource.dataSource.file),
104
+ ID_FIELD: resource.idField,
105
+ SELECT_PROJECTION: resource.selectFields.map((f) => `${f}: row.${f}`).join(', '),
106
+ FETCH_ROUTE_FILE: resource.fetchRoute ? path.relative(repoRoot, resource.fetchRoute.file) : '(unknown)',
107
+ FETCH_ROUTE_LINE: resource.fetchRoute ? resource.fetchRoute.line : '',
108
+ };
109
+ // D-resolver-policy-split: CONTRACT_REF/FEATURE_UID live ONLY in the policy unit now, not
110
+ // the resolver unit -- FEATURE_ID is the only per-feature substitution the resolver itself
111
+ // still carries, so its own pristineRenderFor only ever needs to swap that one var.
112
+ const policyVars = { FEATURE_ID: featureId, RESOURCE_TYPE: resource.type, CONTRACT_REF: contractRef, FEATURE_UID: featureUid };
113
+ return [
114
+ {
71
115
  id: 'resolver.ts.tmpl',
72
116
  resourceType: resource.type,
73
117
  module: plan.module,
74
118
  templatePath: RESOLVER_TEMPLATE,
75
119
  targetAbs,
76
120
  rendered: render(RESOLVER_TEMPLATE, vars),
77
- // FEATURE_ID is the only per-feature substitution (mirrors java-spring's/python-fastapi's
78
- // own resolver templates -- no other var here changes between features for the SAME
79
- // resource), so recovering the pristine render under a different owner is exactly the
80
- // same render with FEATURE_ID swapped.
81
121
  pristineRenderFor: (ownerId) => render(RESOLVER_TEMPLATE, { ...vars, FEATURE_ID: ownerId }),
82
- };
83
- });
122
+ },
123
+ {
124
+ id: 'resolverPolicy.ts.tmpl',
125
+ resourceType: resource.type,
126
+ module: plan.module,
127
+ templatePath: RESOLVER_POLICY_TEMPLATE,
128
+ targetAbs: policyTargetAbs,
129
+ rendered: render(RESOLVER_POLICY_TEMPLATE, policyVars),
130
+ // Only FEATURE_ID varies by owner for the resolver unit above; CONTRACT_REF/FEATURE_UID
131
+ // vary by owner HERE, exactly like java-spring's/python-fastapi's own policy unit.
132
+ pristineRenderFor: (ownerId) => render(RESOLVER_POLICY_TEMPLATE, {
133
+ ...policyVars,
134
+ FEATURE_ID: ownerId,
135
+ CONTRACT_REF: ownerId === featureId ? contractRef : contractRefFor(ownerId),
136
+ FEATURE_UID: ownerId === featureId ? featureUid : featureUidFor(ownerId),
137
+ }),
138
+ },
139
+ ];
140
+ });
84
141
 
85
142
  const orphanScan = (!resourceFilter && plan.module) ? {
86
143
  dir: resolversDir,
@@ -104,25 +161,57 @@ export function emitTypeScriptExpress({ repoRoot, featureId, plan, resourceFilte
104
161
  // imported, or that resource type silently stops being servable. `render()` is called by
105
162
  // emitUnits() itself AFTER its resolver loop writes this run's own files, so this always
106
163
  // sees the final on-disk listing -- now conflict-safe/manifest-tracked like every other
107
- // generated file, no longer unconditional (unlike migration.sql, which stays that way).
108
- postResolverUnit: {
109
- id: 'resolvers_index.ts.tmpl',
110
- templatePath: RESOLVERS_INDEX_TEMPLATE,
111
- targetAbs: resolversIndexPath,
112
- render: () => {
113
- const currentResolverFiles = fs.existsSync(resolversDir)
114
- ? fs.readdirSync(resolversDir).filter((f) => f.endsWith('.ts') && f !== 'resolvers_index.ts').sort()
115
- : [];
116
- const imports = currentResolverFiles.map((f) => `import './${f.replace(/\.ts$/, '')}';`).join('\n');
117
- return render(RESOLVERS_INDEX_TEMPLATE, { IMPORTS: imports });
164
+ // generated file. D-typescript-express-registry-parity: excludes `*Policy.ts` files
165
+ // explicitly -- those have no `register(...)` side effect of their own (their sibling
166
+ // resolver file already imports them directly), importing them a second time here would
167
+ // be a spurious, pointless barrel entry.
168
+ // D-write-safety-phase0/D-typescript-express-registry-parity: migration.sql now shares
169
+ // this array with resolvers_index.ts (widened from a single optional unit to an array
170
+ // specifically so this provider could have both at once -- see D-typescript-express-registry-parity
171
+ // in DECISIONS.md for why migration.sql needed the SAME manifest-tracked treatment
172
+ // java-spring/python-fastapi already have, from day one).
173
+ postResolverUnits: [
174
+ {
175
+ id: 'resolvers_index.ts.tmpl',
176
+ templatePath: RESOLVERS_INDEX_TEMPLATE,
177
+ targetAbs: resolversIndexPath,
178
+ render: () => {
179
+ const currentResolverFiles = fs.existsSync(resolversDir)
180
+ ? fs.readdirSync(resolversDir).filter((f) => f.endsWith('.ts') && f !== 'resolvers_index.ts' && !f.endsWith('Policy.ts')).sort()
181
+ : [];
182
+ const imports = currentResolverFiles.map((f) => `import './${f.replace(/\.ts$/, '')}';`).join('\n');
183
+ return render(RESOLVERS_INDEX_TEMPLATE, { IMPORTS: imports });
184
+ },
118
185
  },
119
- },
186
+ {
187
+ id: 'migration.sql.tmpl', templatePath: MIGRATION_TEMPLATE, targetAbs: migrationPath,
188
+ render: () => render(MIGRATION_TEMPLATE, { FEATURE_ID: featureId }),
189
+ kind: 'migration', ownership: 'feature', owner: featureId,
190
+ },
191
+ ],
120
192
  });
121
193
 
194
+ // D-write-safety-phase1 (item 2), ported here: per-resource, conditional on enforceRegistry
195
+ // actually being on -- mirrors java-spring's/python-fastapi's own registrationGaps exactly.
196
+ const registrationGaps = [];
197
+ const postEmitNotes = [
198
+ `NOT done automatically: wiring the generated router into your app -- add "import { router as handlesRouter } from './handles/router';" and mount it via your app's own router-composition file (e.g. app.use(handlesRouter)) by hand.`,
199
+ 'NOT done automatically: wrapping an existing route handler with recordSnapshot(...) (see handles/recordSnapshotWrapper.ts) to have it register/snapshot automatically. Codegen never touches an existing business logic file.',
200
+ ];
201
+ if (enforceRegistry) {
202
+ for (const resource of willGenerate) {
203
+ const fetchRouteFile = resource.fetchRoute?.file ?? null;
204
+ if (hasRecordSnapshotWrapper(fetchRouteFile)) continue;
205
+ const relFile = fetchRouteFile ? path.relative(repoRoot, fetchRouteFile) : '(unknown)';
206
+ const note = `${resource.type}: --enforce-registry is on, but no import of recordSnapshotWrapper.ts was found anywhere in ${relFile} -- this resource may never get its first HandleRegistry row, and every fetch()/patch() call against it will 404 until something registers it. Wrap ${resource.type}'s own create-flow route with recordSnapshot(...) (or call registerHandle() by hand at least once per resource), then re-emit. See D-handle-registry-enforcement in DECISIONS.md for the full bootstrapping explanation.`;
207
+ postEmitNotes.push(note);
208
+ registrationGaps.push({ resourceType: resource.type, file: relFile, note });
209
+ }
210
+ }
211
+
122
212
  return {
123
213
  ...result,
124
- postEmitNotes: [
125
- `NOT done automatically: wiring the generated router into your app -- add "import { router as handlesRouter } from './handles/router';" and mount it via your app's own router-composition file (e.g. app.use(handlesRouter)) by hand.`,
126
- ],
214
+ postEmitNotes,
215
+ registrationGaps,
127
216
  };
128
217
  }
@@ -64,12 +64,13 @@ export function emitObserveTypeScriptExpress({ repoRoot, featureId, contract, pl
64
64
 
65
65
  const result = emitUnits({ repoRoot, featureId, provider: 'typescript-express', force, reason, infraUnits, resolverUnits: [], orphanScan: null, dryRun, computeDiff });
66
66
 
67
- // The projected observed-schema.json resource -- regenerated unconditionally every run, like
68
- // handles' own migration.sql (and both other providers' own observe schema resource), for the
69
- // identical reason: nobody hand-finishes a generated data file, so O2-style conflict tracking
70
- // buys nothing here. `kind: 'spec'` matches migration.sql's own action-reporting convention --
71
- // NOT the `kind: 'infra'` A13 gave resolvers_index.ts, which was a genuinely hand-editable
72
- // barrel; this is a generated data file, same class as migration.sql, not that one.
67
+ // The projected observed-schema.json resource -- regenerated unconditionally every run (unlike
68
+ // handles' own migration.sql, which moved to manifest tracking in D-write-safety-phase0; this
69
+ // file, and both other providers' own observe schema resource, stay unconditional: nobody
70
+ // hand-finishes a generated data file, so O2-style conflict tracking buys nothing here).
71
+ // `kind: 'spec'` still means "always regenerated, not conflict-tracked" -- NOT the
72
+ // `kind: 'infra'` A13 gave resolvers_index.ts, which was a genuinely hand-editable barrel;
73
+ // this is a generated data file, a different class.
73
74
  const operations = {};
74
75
  for (const [opId, opContract] of Object.entries(contract.operations)) {
75
76
  operations[opId] = projectOperation(opContract);
@@ -55,10 +55,20 @@ export function encodeHandle(kind: string, type: string, uuid: string, pointer:
55
55
  return `sbf1_${base64url(Buffer.from(raw, 'utf8'))}`;
56
56
  }
57
57
 
58
+ // D-handle-identity-contract-freeze (Phase 3): scheme-dispatch table, not a hardcoded 'sbf1_'
59
+ // check -- reserves the discriminant for a future `sbf2_` scheme (Phase 6, capability-scoped
60
+ // handles) to register itself here additively, without ever changing how an existing 'sbf1_'
61
+ // token decodes. encodeHandle() is untouched -- it still only ever emits 'sbf1_' tokens.
62
+ const HANDLE_DECODERS: Record<string, (token: string) => DecodedHandle> = { sbf1: decodeSbf1Handle };
63
+
58
64
  export function decodeHandle(token: string): DecodedHandle {
59
- if (typeof token !== 'string' || !token.startsWith('sbf1_')) {
60
- throw new Error('not an sbf1 handle (missing "sbf1_" prefix)');
61
- }
65
+ const scheme = typeof token === 'string' && token.includes('_') ? token.slice(0, token.indexOf('_')) : token;
66
+ const decoder = typeof scheme === 'string' ? HANDLE_DECODERS[scheme] : undefined;
67
+ if (!decoder) throw new Error('not an sbf1 handle (missing "sbf1_" prefix)');
68
+ return decoder(token);
69
+ }
70
+
71
+ function decodeSbf1Handle(token: string): DecodedHandle {
62
72
  if (token.length > MAX_HANDLE_TOKEN_LENGTH) {
63
73
  throw new Error(`handle token exceeds the maximum length of ${MAX_HANDLE_TOKEN_LENGTH} characters`);
64
74
  }
@@ -0,0 +1,89 @@
1
+ // Generated by backend-skeleton. Do not hand-edit -- change the source template and regenerate.
2
+ //
3
+ // G5 follow-up (D-typescript-express-registry-parity): mirrors java-spring's HandleRegistry.java.tmpl/
4
+ // HandleSnapshot.java.tmpl and python-fastapi's tables.py.tmpl -- same table names, columns, and
5
+ // unique(resource_type, resource_uid, pointer) constraint, so all three providers' generated
6
+ // schemas agree byte-for-byte at the DDL level (see the emitted migration.sql). NOT the same
7
+ // concept as this package's own registry.ts -- that file is an in-process type->resolver
8
+ // dispatch map; HandleRegistry here is a real database table. The names collide only because
9
+ // Java's own naming does; treat them as unrelated.
10
+ //
11
+ // `payload` is a native `jsonb` column -- TypeORM (de)serializes at the DB boundary automatically,
12
+ // the same no-manual-round-trip safety python-fastapi's own tables.py already has (Java's original
13
+ // `payload` column was a manually (de)serialized String, the exact bug class this structurally
14
+ // cannot reproduce).
15
+ //
16
+ // Requires `experimentalDecorators`/`emitDecoratorMetadata` in the target app's own tsconfig --
17
+ // already required by this provider's own real oracle (any TypeORM app already needs both for its
18
+ // OWN `@Entity()` classes; this is not a new requirement this file introduces). Unlike codec.ts
19
+ // (deliberately decorator-free so it can run standalone via `node --experimental-strip-types`),
20
+ // this file is inherently coupled to a real TypeORM build the way every entity in this ecosystem
21
+ // already is.
22
+ import { Entity, PrimaryColumn, Column, CreateDateColumn, PrimaryGeneratedColumn } from 'typeorm';
23
+
24
+ @Entity({ name: 'sbf_handle' })
25
+ export class HandleRegistry {
26
+ @PrimaryColumn('uuid')
27
+ handleUid!: string;
28
+
29
+ // 'r' (resource), 'f' (field), or 'o' (operation instance -- reserved, unused).
30
+ @Column('text')
31
+ kind!: string;
32
+
33
+ @Column('text')
34
+ resourceType!: string;
35
+
36
+ @Column('uuid')
37
+ resourceUid!: string;
38
+
39
+ // RFC 6901 JSON Pointer, null for kind='r'.
40
+ @Column('text', { nullable: true })
41
+ pointer!: string | null;
42
+
43
+ @Column('uuid')
44
+ featureUid!: string;
45
+
46
+ @Column('text', { nullable: true })
47
+ operationId!: string | null;
48
+
49
+ @Column('text')
50
+ contractRef!: string;
51
+
52
+ @CreateDateColumn({ type: 'timestamptz' })
53
+ createdAt!: Date;
54
+
55
+ @Column('timestamptz', { nullable: true })
56
+ revokedAt!: Date | null;
57
+
58
+ @Column('text', { nullable: true })
59
+ revokedReason!: string | null;
60
+ }
61
+
62
+ @Entity({ name: 'sbf_handle_snapshot' })
63
+ export class HandleSnapshot {
64
+ @PrimaryGeneratedColumn({ type: 'bigint' })
65
+ snapshotId!: string;
66
+
67
+ @Column('uuid')
68
+ handleUid!: string;
69
+
70
+ // 'request', 'response', or 'error' -- what kind of payload this envelope holds.
71
+ @Column('text')
72
+ envelopeDir!: string;
73
+
74
+ @Column('text')
75
+ operationId!: string;
76
+
77
+ // Hash of the feature contract this snapshot was recorded against. `recover` compares this to
78
+ // the CURRENT contract's hash -- a mismatch means the contract has since changed shape, and
79
+ // `recover` must say so explicitly (a schemaDrift marker) rather than silently returning a
80
+ // payload the current contract no longer describes.
81
+ @Column('text')
82
+ contractHash!: string;
83
+
84
+ @Column('jsonb')
85
+ payload!: unknown;
86
+
87
+ @CreateDateColumn({ type: 'timestamptz' })
88
+ recordedAt!: Date;
89
+ }
@@ -0,0 +1,81 @@
1
+ // Generated by backend-skeleton. Do not hand-edit -- change the source template and regenerate.
2
+ //
3
+ // G5 follow-up (D-typescript-express-registry-parity): mirrors Java's HandleService.java.tmpl /
4
+ // python-fastapi's handle_service.py.tmpl -- the explicit API that makes a real GET
5
+ // /handles/:handle/recover reachable. Deliberately never auto-invoked by anything generated
6
+ // elsewhere and never wired into any EXISTING business logic file -- call these functions
7
+ // explicitly from your own service code at the point a resource is actually created/updated, or
8
+ // wrap an existing service function with `recordSnapshot(...)` (see recordSnapshotWrapper.ts) to
9
+ // have it called for you.
10
+ //
11
+ // Named `registerHandle`/`revokeHandle` (not `register`/`revoke`) -- `registry.ts`'s own
12
+ // `register()` is a completely unrelated function (in-process resolver-type dispatch, not a
13
+ // database write); the names collide only because both files use the word "register" for
14
+ // different things. Plain exported functions taking `dataSource` explicitly, not a class with
15
+ // injected dependencies -- matches this provider's own established convention (every resolver
16
+ // already imports its own app-wide DataSource singleton directly; there is no DI container here
17
+ // the way Spring's @Service beans have, or even FastAPI's own Depends()).
18
+ import type { DataSource } from 'typeorm';
19
+ import { HandleRegistry, HandleSnapshot } from './handleEntities';
20
+ import { deriveHandleUid, encodeHandle } from './codec';
21
+
22
+ export interface RegisterHandleArgs {
23
+ kind: string;
24
+ type: string;
25
+ resourceUid: string;
26
+ pointer: string | null;
27
+ featureUid: string;
28
+ operationId: string | null;
29
+ contractRef: string;
30
+ }
31
+
32
+ // Derives handleUid and UPSERTS the registry row -- the schema's own
33
+ // unique(resource_type, resource_uid, pointer) constraint means the SAME (kind, type,
34
+ // resource_uid, pointer) tuple always derives the SAME handleUid, so re-registering it is
35
+ // expected, not an error: an existing row has its featureUid/operationId/contractRef refreshed,
36
+ // but revokedAt/revokedReason are NEVER touched here -- re-registering a revoked handle must
37
+ // never silently un-revoke it. Returns the encoded handle token.
38
+ export async function registerHandle(dataSource: DataSource, args: RegisterHandleArgs): Promise<string> {
39
+ const { kind, type, resourceUid, pointer, featureUid, operationId, contractRef } = args;
40
+ const token = encodeHandle(kind, type, resourceUid, pointer);
41
+ const handleUid = deriveHandleUid(kind, type, resourceUid, pointer);
42
+ const repo = dataSource.getRepository(HandleRegistry);
43
+ const existing = await repo.findOne({ where: { handleUid } });
44
+ if (existing == null) {
45
+ await repo.save(repo.create({ handleUid, kind, resourceType: type, resourceUid, pointer, featureUid, operationId, contractRef, revokedAt: null, revokedReason: null }));
46
+ } else {
47
+ existing.featureUid = featureUid;
48
+ existing.operationId = operationId;
49
+ existing.contractRef = contractRef;
50
+ await repo.save(existing);
51
+ }
52
+ return token;
53
+ }
54
+
55
+ // Records one envelope for an already-registered handle. `payload` is a plain
56
+ // object/array/scalar (already JSON-shaped), stored directly into the native jsonb column -- no
57
+ // manual JSON.stringify/JSON.parse round-trip exists here for `recover` to get wrong later.
58
+ export async function recordSnapshot(dataSource: DataSource, handleUid: string, envelopeDir: string, operationId: string, contractHash: string, payload: unknown): Promise<void> {
59
+ const repo = dataSource.getRepository(HandleSnapshot);
60
+ await repo.save(repo.create({ handleUid, envelopeDir, operationId, contractHash, payload }));
61
+ }
62
+
63
+ export async function revokeHandle(dataSource: DataSource, handleUid: string, reason: string): Promise<void> {
64
+ const repo = dataSource.getRepository(HandleRegistry);
65
+ const existing = await repo.findOne({ where: { handleUid } });
66
+ if (existing != null) {
67
+ existing.revokedAt = new Date();
68
+ existing.revokedReason = reason;
69
+ await repo.save(existing);
70
+ }
71
+ }
72
+
73
+ // Retention is EXPOSED, not auto-scheduled -- nothing generated here calls this automatically.
74
+ // Deciding how long snapshots should live, and whether a background job is even appropriate for
75
+ // this application, is left to a human -- the same boundary this provider's own migration.sql
76
+ // already draws around never applying itself.
77
+ export async function pruneSnapshotsOlderThan(dataSource: DataSource, cutoff: Date): Promise<number> {
78
+ const repo = dataSource.getRepository(HandleSnapshot);
79
+ const result = await repo.createQueryBuilder().delete().where('recorded_at < :cutoff', { cutoff }).execute();
80
+ return result.affected ?? 0;
81
+ }
@@ -0,0 +1,36 @@
1
+ -- Generated by backend-skeleton (bskel handles emit) for feature {{FEATURE_ID}}.
2
+ -- NOT applied automatically -- review it and apply yourself (e.g. via `psql` or your own
3
+ -- TypeORM migration, wrapped in a `queryRunner.query(...)` migration file if you use one). See
4
+ -- D-config-patch / D-migration-scope in DECISIONS.md for why backend-skeleton never applies
5
+ -- schema changes on its own. Same table names/columns as the java-spring/python-fastapi
6
+ -- providers' own migration.sql.tmpl, so all three providers' generated schemas agree at the DDL
7
+ -- level.
8
+
9
+ create table if not exists sbf_handle (
10
+ handle_uid uuid primary key,
11
+ kind text not null check (kind in ('r', 'f', 'o')),
12
+ resource_type text not null,
13
+ resource_uid uuid not null,
14
+ pointer text,
15
+ feature_uid uuid not null,
16
+ operation_id text,
17
+ contract_ref text not null,
18
+ created_at timestamptz not null default now(),
19
+ revoked_at timestamptz,
20
+ revoked_reason text,
21
+ unique (resource_type, resource_uid, pointer)
22
+ );
23
+
24
+ create index if not exists ix_sbf_handle_resource on sbf_handle (resource_type, resource_uid);
25
+
26
+ create table if not exists sbf_handle_snapshot (
27
+ snapshot_id bigserial primary key,
28
+ handle_uid uuid not null references sbf_handle (handle_uid),
29
+ envelope_dir text not null check (envelope_dir in ('request', 'response', 'error')),
30
+ operation_id text not null,
31
+ contract_hash text not null,
32
+ payload jsonb not null,
33
+ recorded_at timestamptz not null default now()
34
+ );
35
+
36
+ create index if not exists ix_sbf_handle_snapshot_handle_uid on sbf_handle_snapshot (handle_uid, recorded_at desc);
@@ -0,0 +1,123 @@
1
+ // Generated by backend-skeleton. Do not hand-edit -- change the source template and regenerate.
2
+ //
3
+ // D-typescript-express-registry-parity: the opt-in AUTOMATIC half of the handle lifecycle --
4
+ // apply `recordSnapshot(...)` to wrap an EXISTING service function (never generated onto one --
5
+ // a human decides which functions are worth handle-tracking) to have it automatically register
6
+ // the resource-level handle and record the request/response/error envelope around every call.
7
+ //
8
+ // Java splits this into a marker annotation (@RecordHandleSnapshot) + a Spring AOP interceptor
9
+ // (HandleAspect) because Java has no other way to intercept a method call. Python collapses both
10
+ // into one decorator, because Python decorators natively ARE that ecosystem's interception
11
+ // mechanism. TypeScript has NEITHER available here: TS decorators are an experimental,
12
+ // `experimentalDecorators`-gated feature that targets class methods, not the standalone async
13
+ // functions this provider's own resolvers/routes are written as -- and this provider's own
14
+ // codec.ts already establishes "zero non-erasable TypeScript syntax (no decorators...)" as a
15
+ // real, load-bearing constraint. The equivalent here is a HIGHER-ORDER FUNCTION: one mechanism,
16
+ // same "no marker/interceptor split" property Python's decorator has, without decorator syntax.
17
+ //
18
+ // Only an async wrapper exists (no sync variant) -- unlike Python, where a decorator can wrap
19
+ // either a sync or async function and must detect which at decoration time, every Express
20
+ // handler and TypeORM repository call in this ecosystem is already async by convention, so
21
+ // there's no second code path to maintain.
22
+ //
23
+ // `resourceUidArg` is an argument INDEX (not a parameter NAME) -- TypeScript/JavaScript has no
24
+ // runtime reflection equivalent to Python's `inspect.signature(fn).bind(...)`, so this cannot
25
+ // resolve a named parameter the way the Python port does; pass the zero-based position of the
26
+ // resource-uid argument in the wrapped function's own parameter list. `redact` is an explicit
27
+ // array of JSON Pointers, never a guessed heuristic. A failure recording a snapshot is ALWAYS
28
+ // logged and swallowed, never allowed to fail the real business call it wraps -- snapshot
29
+ // recording is best-effort observability, not a new way for an unrelated call to start failing.
30
+ //
31
+ // Requires nothing extra installed (unlike Java's spring-boot-starter-aop) -- a plain function
32
+ // needs no framework support -- but still requires a human to apply it to their own code;
33
+ // codegen never touches an existing business logic file.
34
+ //
35
+ // Example:
36
+ // export const updateOrganization = recordSnapshot(
37
+ // { resourceType: 'Organization', operationId: 'updateOrganization', resourceUidArg: 0, redact: ['/internalNote'] },
38
+ // async (organizationId: string, req: UpdateOrganizationRequest): Promise<OrganizationResponse> => { ... },
39
+ // );
40
+ import type { DataSource } from 'typeorm';
41
+ import { registerHandle, recordSnapshot as persistSnapshot } from './handleService';
42
+ import { deriveHandleUid } from './codec';
43
+ import { resolverFor } from './registry';
44
+
45
+ export interface RecordSnapshotOptions {
46
+ resourceType: string;
47
+ operationId: string;
48
+ resourceUidArg: number;
49
+ redact?: string[];
50
+ }
51
+
52
+ function redactInPlace(obj: unknown, pointer: string): void {
53
+ if (!pointer.startsWith('/')) return;
54
+ const parts = pointer.slice(1).split('/').map((p) => p.replace(/~1/g, '/').replace(/~0/g, '~'));
55
+ let current: unknown = obj;
56
+ for (const part of parts.slice(0, -1)) {
57
+ if (current == null) return;
58
+ current = Array.isArray(current) ? current[Number(part)] : (current as Record<string, unknown>)[part];
59
+ }
60
+ const leaf = parts[parts.length - 1];
61
+ if (Array.isArray(current)) {
62
+ const idx = Number(leaf);
63
+ if (!Number.isNaN(idx) && idx < current.length) current[idx] = '***REDACTED***';
64
+ } else if (current != null && typeof current === 'object') {
65
+ if (leaf in (current as Record<string, unknown>)) (current as Record<string, unknown>)[leaf] = '***REDACTED***';
66
+ }
67
+ }
68
+
69
+ function safely(action: () => Promise<void>, handleUid: string, envelopeDir: string): void {
70
+ action().catch((err) => {
71
+ // eslint-disable-next-line no-console
72
+ console.warn(`recordSnapshot: could not record ${envelopeDir} snapshot for handle ${handleUid} -- the wrapped call proceeds unaffected`, err);
73
+ });
74
+ }
75
+
76
+ export function recordSnapshot<Args extends unknown[], R>(
77
+ options: RecordSnapshotOptions,
78
+ dataSource: DataSource,
79
+ fn: (...args: Args) => Promise<R>,
80
+ ): (...args: Args) => Promise<R> {
81
+ const { resourceType, operationId, resourceUidArg, redact = [] } = options;
82
+ return async (...args: Args): Promise<R> => {
83
+ const resolver = resolverFor(resourceType);
84
+ if (resolver == null) {
85
+ // eslint-disable-next-line no-console
86
+ console.warn(`recordSnapshot: no resolver registered for resourceType "${resourceType}" -- skipping snapshot recording, the wrapped call proceeds unaffected`);
87
+ return fn(...args);
88
+ }
89
+ const resourceUid = args[resourceUidArg];
90
+ if (typeof resourceUid !== 'string') {
91
+ // eslint-disable-next-line no-console
92
+ console.warn(`recordSnapshot: resourceUidArg ${resourceUidArg} on a call to a wrapped "${resourceType}" function did not resolve to a string argument -- skipping snapshot recording, the wrapped call proceeds unaffected`);
93
+ return fn(...args);
94
+ }
95
+ const handleUid = deriveHandleUid('r', resourceType, resourceUid, null);
96
+
97
+ const record = (envelopeDir: string, payload: unknown) => {
98
+ if (payload != null && typeof payload === 'object') {
99
+ for (const pointer of redact) redactInPlace(payload, pointer);
100
+ }
101
+ safely(async () => {
102
+ await registerHandle(dataSource, { kind: 'r', type: resourceType, resourceUid, pointer: null, featureUid: resolver.featureUid, operationId, contractRef: resolver.contractRef });
103
+ await persistSnapshot(dataSource, handleUid, envelopeDir, operationId, resolver.contractRef, payload);
104
+ }, handleUid, envelopeDir);
105
+ };
106
+
107
+ // The remaining arguments once resourceUidArg is excluded -- the sole survivor UNWRAPPED (the
108
+ // common case: one DTO), matching Java's/Python's own request-payload projection exactly, so
109
+ // a `redact` pointer written against the DTO's own fields resolves correctly.
110
+ const rest = args.filter((_, i) => i !== resourceUidArg);
111
+ record('request', rest.length === 1 ? rest[0] : rest);
112
+
113
+ let result: R;
114
+ try {
115
+ result = await fn(...args);
116
+ } catch (err) {
117
+ record('error', { message: err instanceof Error ? err.message : String(err) });
118
+ throw err;
119
+ }
120
+ record('response', result);
121
+ return result;
122
+ };
123
+ }