backend-skeleton 1.0.0 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -4
- package/bin/bskel.mjs +125 -18
- package/contracts/export.mjs +39 -4
- package/contracts/openapi.mjs +292 -27
- package/contracts/validate.mjs +23 -4
- package/handles/_engine.mjs +75 -32
- package/handles/capability-codec.mjs +94 -0
- package/handles/codec.mjs +13 -3
- package/handles/providers/java-spring/emit.mjs +78 -33
- package/handles/providers/java-spring/observe.mjs +4 -3
- package/handles/providers/java-spring/plan.mjs +51 -7
- package/handles/providers/java-spring/templates/HandleCodec.java.tmpl +19 -1
- package/handles/providers/java-spring/templates/HandleController.java.tmpl +13 -7
- package/handles/providers/java-spring/templates/HandleService.java.tmpl +21 -2
- package/handles/providers/java-spring/templates/RecordHandleSnapshot.java.tmpl +1 -1
- package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +24 -3
- package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +8 -2
- package/handles/providers/java-spring.mjs +8 -0
- package/handles/providers/python-fastapi/emit.mjs +21 -26
- package/handles/providers/python-fastapi/observe.mjs +6 -5
- package/handles/providers/python-fastapi/templates/codec.py.tmpl +18 -3
- package/handles/providers/python-fastapi/templates/record_snapshot.py.tmpl +32 -4
- package/handles/providers/python-fastapi.mjs +3 -3
- package/handles/providers/typescript-express/emit.mjs +135 -46
- package/handles/providers/typescript-express/observe.mjs +7 -6
- package/handles/providers/typescript-express/plan.mjs +8 -1
- package/handles/providers/typescript-express/templates/codec.ts.tmpl +13 -3
- package/handles/providers/typescript-express/templates/handleEntities.ts.tmpl +89 -0
- package/handles/providers/typescript-express/templates/handleService.ts.tmpl +81 -0
- package/handles/providers/typescript-express/templates/migration.sql.tmpl +36 -0
- package/handles/providers/typescript-express/templates/recordSnapshotWrapper.ts.tmpl +123 -0
- package/handles/providers/typescript-express/templates/registry.ts.tmpl +19 -10
- package/handles/providers/typescript-express/templates/resolver.ts.tmpl +13 -0
- package/handles/providers/typescript-express/templates/resolverPolicy.ts.tmpl +20 -0
- package/handles/providers/typescript-express/templates/router.ts.tmpl +113 -2
- package/handles/providers/typescript-express.mjs +7 -4
- package/lib/cli.mjs +11 -2
- package/lib/exit-codes.mjs +21 -0
- package/lib/verify.mjs +23 -6
- package/package.json +5 -2
- package/scanners/adapters/_java-spring-analyzer.mjs +25 -2
- package/scanners/adapters/java-spring.mjs +108 -10
- package/scanners/adapters/javascript-express.mjs +46 -13
- package/scanners/adapters/python-fastapi.mjs +63 -18
- package/scanners/adapters/typescript-express.mjs +33 -7
- package/schemas/feature-contract.schema.json +3 -3
- package/schemas/handles-plan.schema.json +2 -0
- package/schemas/oracle-manifest.schema.json +58 -0
- package/schemas/stack-record.schema.json +6 -1
- 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)
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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
|
|
68
|
-
// handles' own migration.sql
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
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);
|
|
@@ -172,11 +172,18 @@ export function plan({ repoRoot, scanReport, module: moduleName, resourceFilter
|
|
|
172
172
|
for (const entity of targetModule.entities) {
|
|
173
173
|
if (resourceFilter && !resourceFilter.includes(entity.className)) continue;
|
|
174
174
|
const fetchRoute = findFetchRoute(targetModule.controllers, entity.className);
|
|
175
|
-
|
|
175
|
+
// D-typescript-express-inline-handlers: `method === null` means the scanner found a real
|
|
176
|
+
// route but its handler is an inline function expression, not a named export -- there is
|
|
177
|
+
// genuinely nothing for resolveHandlerFile()'s import/barrel-hop search to correlate to, so
|
|
178
|
+
// this is checked explicitly (a clear, named reason) rather than relying on the incidental
|
|
179
|
+
// fact that a regex built from the literal string "null" also happens not to match anything.
|
|
180
|
+
const handlerFile = fetchRoute && fetchRoute.method ? resolveHandlerFile(fetchRoute.file, fetchRoute.method, srcRoot) : null;
|
|
176
181
|
const selectFields = handlerFile ? findSelectAllowList(handlerFile) : null;
|
|
177
182
|
|
|
178
183
|
if (!fetchRoute) {
|
|
179
184
|
notes.push(`${entity.className}: no single-resource GET route found on a router whose name contains "${entity.className}" -- fetch() will need to be hand-written`);
|
|
185
|
+
} else if (!fetchRoute.method) {
|
|
186
|
+
notes.push(`${entity.className}: the single-resource GET route's handler is an inline function expression, not a named export -- nothing to correlate to a defining file, resolver NOT generated.`);
|
|
180
187
|
} else if (!handlerFile) {
|
|
181
188
|
notes.push(`${entity.className}: could not resolve ${fetchRoute.method}'s own defining file (import, or one barrel hop, from ${path.relative(repoRoot, fetchRoute.file)}) -- resolver NOT generated.`);
|
|
182
189
|
} else if (!selectFields) {
|
|
@@ -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
|
-
|
|
60
|
-
|
|
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
|
+
}
|