backend-skeleton 1.0.0-beta.9 → 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 (77) hide show
  1. package/README.md +185 -13
  2. package/bin/bskel.mjs +689 -33
  3. package/contracts/emit.mjs +5 -1
  4. package/contracts/export.mjs +65 -7
  5. package/contracts/openapi.mjs +321 -30
  6. package/contracts/validate.mjs +23 -4
  7. package/handles/_engine.mjs +123 -30
  8. package/handles/capability-codec.mjs +94 -0
  9. package/handles/codec.mjs +13 -3
  10. package/handles/providers/java-spring/emit.mjs +78 -33
  11. package/handles/providers/java-spring/observe.mjs +4 -3
  12. package/handles/providers/java-spring/plan.mjs +73 -16
  13. package/handles/providers/java-spring/templates/HandleCodec.java.tmpl +19 -1
  14. package/handles/providers/java-spring/templates/HandleController.java.tmpl +19 -9
  15. package/handles/providers/java-spring/templates/HandleService.java.tmpl +21 -2
  16. package/handles/providers/java-spring/templates/RecordHandleSnapshot.java.tmpl +1 -1
  17. package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +36 -9
  18. package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +8 -2
  19. package/handles/providers/java-spring.mjs +8 -0
  20. package/handles/providers/python-fastapi/emit.mjs +21 -26
  21. package/handles/providers/python-fastapi/observe.mjs +6 -5
  22. package/handles/providers/python-fastapi/templates/codec.py.tmpl +18 -3
  23. package/handles/providers/python-fastapi/templates/record_snapshot.py.tmpl +129 -39
  24. package/handles/providers/python-fastapi.mjs +3 -3
  25. package/handles/providers/typescript-express/emit.mjs +144 -55
  26. package/handles/providers/typescript-express/observe.mjs +102 -0
  27. package/handles/providers/typescript-express/templates/codec.ts.tmpl +13 -3
  28. package/handles/providers/typescript-express/templates/contractCheck.ts.tmpl +136 -0
  29. package/handles/providers/typescript-express/templates/handleEntities.ts.tmpl +89 -0
  30. package/handles/providers/typescript-express/templates/handleService.ts.tmpl +81 -0
  31. package/handles/providers/typescript-express/templates/migration.sql.tmpl +36 -0
  32. package/handles/providers/typescript-express/templates/observeContract.ts.tmpl +146 -0
  33. package/handles/providers/typescript-express/templates/observedSchema.ts.tmpl +116 -0
  34. package/handles/providers/typescript-express/templates/recordSnapshotWrapper.ts.tmpl +123 -0
  35. package/handles/providers/typescript-express/templates/registry.ts.tmpl +19 -10
  36. package/handles/providers/typescript-express/templates/resolver.ts.tmpl +13 -0
  37. package/handles/providers/typescript-express/templates/resolverPolicy.ts.tmpl +20 -0
  38. package/handles/providers/typescript-express/templates/router.ts.tmpl +113 -2
  39. package/handles/providers/typescript-express.mjs +7 -4
  40. package/lib/attest.mjs +40 -0
  41. package/lib/cli.mjs +136 -5
  42. package/lib/cross-feature-collisions.mjs +286 -0
  43. package/lib/diff.mjs +35 -0
  44. package/lib/exit-codes.mjs +21 -0
  45. package/lib/fsutil.mjs +7 -2
  46. package/lib/gate-definitions.mjs +85 -1
  47. package/lib/gates.mjs +5 -1
  48. package/lib/http-server.mjs +192 -6
  49. package/lib/lock.mjs +68 -15
  50. package/lib/patch-kinds.mjs +52 -0
  51. package/lib/patch-transactions.mjs +206 -0
  52. package/lib/serve-ui.html +211 -0
  53. package/lib/verify.mjs +23 -6
  54. package/lib/workflow.mjs +31 -3
  55. package/package.json +8 -2
  56. package/scanners/adapters/_java-spring-analyzer.mjs +9 -1
  57. package/scanners/adapters/java-spring.mjs +114 -10
  58. package/scanners/adapters/javascript-express.mjs +46 -13
  59. package/scanners/adapters/python-fastapi.mjs +9 -1
  60. package/scanners/adapters/typescript-express.mjs +19 -2
  61. package/scanners/db/ddl-apply.mjs +253 -0
  62. package/scanners/db/introspect.mjs +61 -32
  63. package/scanners/db/migrations.mjs +73 -18
  64. package/schemas/cross-feature-report.schema.json +66 -0
  65. package/schemas/cross-feature-resolution.schema.json +28 -0
  66. package/schemas/feature-contract.schema.json +3 -3
  67. package/schemas/gate-attestation.schema.json +22 -0
  68. package/schemas/gate-export.schema.json +58 -0
  69. package/schemas/handles-plan.schema.json +2 -0
  70. package/schemas/oracle-manifest.schema.json +58 -0
  71. package/schemas/patch-transaction.schema.json +182 -0
  72. package/schemas/scan-report.schema.json +6 -4
  73. package/schemas/stack-choice.schema.json +12 -1
  74. package/schemas/stack-record.schema.json +6 -1
  75. package/stack/apply.mjs +51 -7
  76. package/stack/catalog/ngrok.yml +8 -2
  77. package/stack/config-apply.mjs +168 -0
@@ -23,6 +23,16 @@ Requires nothing extra installed (unlike Java's spring-boot-starter-aop) -- Pyth
23
23
  no framework support -- but still requires a human to apply it to their own code; codegen never
24
24
  touches an existing business logic file.
25
25
 
26
+ CRITICAL, python-specific correctness requirement (see DECISIONS.md D-runtime-conformance-receipts's
27
+ own async-wrapper note, and observe_contract.py's identical dual-wrapper shape in this same
28
+ directory): detects whether the wrapped function is a coroutine function
29
+ (`inspect.iscoroutinefunction`) at DECORATION time and dispatches to a genuinely separate async
30
+ wrapper (awaiting the wrapped call) vs. sync wrapper (calling it directly). A single wrapper naively
31
+ calling an async function without awaiting it would return an unawaited coroutine object as the
32
+ "result" -- the "response" snapshot would then record that coroutine object instead of the real
33
+ result, and the "error" snapshot path would never fire even when the real async call later raises
34
+ (calling an async function does not raise synchronously; only awaiting it does).
35
+
26
36
  Example:
27
37
  @record_snapshot(resource_type="Organization", operation_id="update_organization",
28
38
  resource_uid_param="organization_id", session_param="session",
@@ -35,6 +45,8 @@ import inspect
35
45
  import logging
36
46
  import uuid
37
47
 
48
+ from sqlmodel import Session
49
+
38
50
  from {{PKG}}.handles import handle_service
39
51
  from {{PKG}}.handles.codec import derive_handle_uid
40
52
  from {{PKG}}.handles.registry import resolver_for
@@ -42,6 +54,24 @@ from {{PKG}}.handles.registry import resolver_for
42
54
  logger = logging.getLogger(__name__)
43
55
 
44
56
 
57
+ def _side_channel_session(caller_session: Session) -> Session:
58
+ """D-handles-pilot-cohort (python-fastapi follow-up to the java-spring transaction-isolation
59
+ finding, D-handle-aspect-transaction-isolation): `handle_service.register`/`record_snapshot`
60
+ each call `session.commit()` -- fine when a human calls them directly on their OWN session
61
+ (handle_service.py's own documented use case), genuinely dangerous when called from THIS
62
+ decorator on the WRAPPED function's session, whose transaction semantics this decorator
63
+ cannot know. Real, concrete risk: a wrapped function that does multiple related writes
64
+ without committing (expecting ITS OWN caller to commit-or-rollback atomically) raises
65
+ mid-transaction -- this decorator's own error-path snapshot recording would call
66
+ `session.commit()` on that SAME session, silently persisting the should-have-rolled-back
67
+ partial write instead of ever reaching a real rollback. A genuinely separate session --
68
+ sharing only the caller's engine/connection pool via `get_bind()`, never its in-flight
69
+ transaction -- makes handle registration/snapshot recording a real side channel, matching
70
+ what `Propagation.REQUIRES_NEW` achieves on the Java side for the identical reason.
71
+ """
72
+ return Session(caller_session.get_bind())
73
+
74
+
45
75
  def _redact(obj, pointer: str) -> None:
46
76
  """Walks to `pointer`'s parent container and blanks the leaf value in place -- genuinely
47
77
  simpler than Java's hand-rolled ObjectNode navigator, since Python containers are natively
@@ -110,45 +140,105 @@ def record_snapshot(*, resource_type: str, operation_id: str, resource_uid_param
110
140
  def decorator(fn):
111
141
  signature = inspect.signature(fn)
112
142
 
113
- @functools.wraps(fn)
114
- def wrapper(*args, **kwargs):
115
- bound = signature.bind(*args, **kwargs)
116
- bound.apply_defaults()
117
-
118
- resolver = resolver_for(resource_type)
119
- if resolver is None:
120
- logger.warning('record_snapshot: no resolver registered for resource_type "%s" on %s -- skipping snapshot recording, the wrapped call proceeds unaffected', resource_type, fn.__qualname__)
121
- return fn(*args, **kwargs)
122
-
123
- resource_uid = bound.arguments.get(resource_uid_param)
124
- if not isinstance(resource_uid, uuid.UUID):
125
- logger.warning('record_snapshot: resource_uid_param "%s" on %s does not resolve to a UUID argument -- skipping snapshot recording, the wrapped call proceeds unaffected', resource_uid_param, fn.__qualname__)
126
- return fn(*args, **kwargs)
127
-
128
- session = bound.arguments.get(session_param)
129
- handle_uid = uuid.UUID(derive_handle_uid("r", resource_type, str(resource_uid), None))
130
- contract_ref = resolver.contract_ref
131
-
132
- def _register_and_record(envelope_dir, payload):
133
- handle_service.register(session, "r", resource_type, resource_uid, None, resolver.feature_uid, operation_id, contract_ref)
134
- handle_service.record_snapshot(session, handle_uid, envelope_dir, operation_id, contract_ref, payload)
135
-
136
- def _record(envelope_dir, payload):
137
- if isinstance(payload, (dict, list)):
138
- for pointer in redact:
139
- _redact(payload, pointer)
140
- _safely(lambda: _register_and_record(envelope_dir, payload), handle_uid, envelope_dir)
141
-
142
- request_payload = _request_payload(bound, resource_uid_param, session_param)
143
- _record("request", request_payload)
144
-
145
- try:
146
- result = fn(*args, **kwargs)
147
- except Exception as exc:
148
- _record("error", {"message": str(exc)})
149
- raise
150
- _record("response", _to_jsonable(result))
151
- return result
143
+ # CRITICAL, python-specific correctness requirement (see DECISIONS.md
144
+ # D-runtime-conformance-receipts's own async-wrapper note, ported here for the same reason):
145
+ # detects whether the wrapped function is a coroutine function (inspect.iscoroutinefunction)
146
+ # at DECORATION time and dispatches to a genuinely separate async wrapper (awaiting the
147
+ # wrapped call) vs. sync wrapper (calling it directly). A single wrapper naively calling an
148
+ # async function without awaiting it returns an unawaited coroutine object as "result" --
149
+ # the "response" snapshot then records that coroutine object instead of the real result, and
150
+ # the "error" snapshot path never fires even when the real async call later raises (calling
151
+ # an async function does not raise synchronously; only awaiting it does). This mirrors
152
+ # observe_contract.py's own dual-wrapper shape exactly, deliberately, so both decorators in
153
+ # this file handle async the same way.
154
+ if inspect.iscoroutinefunction(fn):
155
+ @functools.wraps(fn)
156
+ async def wrapper(*args, **kwargs):
157
+ bound = signature.bind(*args, **kwargs)
158
+ bound.apply_defaults()
159
+
160
+ resolver = resolver_for(resource_type)
161
+ if resolver is None:
162
+ logger.warning('record_snapshot: no resolver registered for resource_type "%s" on %s -- skipping snapshot recording, the wrapped call proceeds unaffected', resource_type, fn.__qualname__)
163
+ return await fn(*args, **kwargs)
164
+
165
+ resource_uid = bound.arguments.get(resource_uid_param)
166
+ if not isinstance(resource_uid, uuid.UUID):
167
+ logger.warning('record_snapshot: resource_uid_param "%s" on %s does not resolve to a UUID argument -- skipping snapshot recording, the wrapped call proceeds unaffected', resource_uid_param, fn.__qualname__)
168
+ return await fn(*args, **kwargs)
169
+
170
+ session = bound.arguments.get(session_param)
171
+ handle_uid = uuid.UUID(derive_handle_uid("r", resource_type, str(resource_uid), None))
172
+ contract_ref = resolver.contract_ref
173
+
174
+ def _register_and_record(envelope_dir, payload):
175
+ # D-handles-pilot-cohort: a genuinely separate session -- see
176
+ # _side_channel_session's own docstring for why this must never be the
177
+ # wrapped function's own `session`.
178
+ with _side_channel_session(session) as side_session:
179
+ handle_service.register(side_session, "r", resource_type, resource_uid, None, resolver.feature_uid, operation_id, contract_ref)
180
+ handle_service.record_snapshot(side_session, handle_uid, envelope_dir, operation_id, contract_ref, payload)
181
+
182
+ def _record(envelope_dir, payload):
183
+ if isinstance(payload, (dict, list)):
184
+ for pointer in redact:
185
+ _redact(payload, pointer)
186
+ _safely(lambda: _register_and_record(envelope_dir, payload), handle_uid, envelope_dir)
187
+
188
+ request_payload = _request_payload(bound, resource_uid_param, session_param)
189
+ _record("request", request_payload)
190
+
191
+ try:
192
+ result = await fn(*args, **kwargs)
193
+ except Exception as exc:
194
+ _record("error", {"message": str(exc)})
195
+ raise
196
+ _record("response", _to_jsonable(result))
197
+ return result
198
+ else:
199
+ @functools.wraps(fn)
200
+ def wrapper(*args, **kwargs):
201
+ bound = signature.bind(*args, **kwargs)
202
+ bound.apply_defaults()
203
+
204
+ resolver = resolver_for(resource_type)
205
+ if resolver is None:
206
+ logger.warning('record_snapshot: no resolver registered for resource_type "%s" on %s -- skipping snapshot recording, the wrapped call proceeds unaffected', resource_type, fn.__qualname__)
207
+ return fn(*args, **kwargs)
208
+
209
+ resource_uid = bound.arguments.get(resource_uid_param)
210
+ if not isinstance(resource_uid, uuid.UUID):
211
+ logger.warning('record_snapshot: resource_uid_param "%s" on %s does not resolve to a UUID argument -- skipping snapshot recording, the wrapped call proceeds unaffected', resource_uid_param, fn.__qualname__)
212
+ return fn(*args, **kwargs)
213
+
214
+ session = bound.arguments.get(session_param)
215
+ handle_uid = uuid.UUID(derive_handle_uid("r", resource_type, str(resource_uid), None))
216
+ contract_ref = resolver.contract_ref
217
+
218
+ def _register_and_record(envelope_dir, payload):
219
+ # D-handles-pilot-cohort: a genuinely separate session -- see
220
+ # _side_channel_session's own docstring for why this must never be the
221
+ # wrapped function's own `session`.
222
+ with _side_channel_session(session) as side_session:
223
+ handle_service.register(side_session, "r", resource_type, resource_uid, None, resolver.feature_uid, operation_id, contract_ref)
224
+ handle_service.record_snapshot(side_session, handle_uid, envelope_dir, operation_id, contract_ref, payload)
225
+
226
+ def _record(envelope_dir, payload):
227
+ if isinstance(payload, (dict, list)):
228
+ for pointer in redact:
229
+ _redact(payload, pointer)
230
+ _safely(lambda: _register_and_record(envelope_dir, payload), handle_uid, envelope_dir)
231
+
232
+ request_payload = _request_payload(bound, resource_uid_param, session_param)
233
+ _record("request", request_payload)
234
+
235
+ try:
236
+ result = fn(*args, **kwargs)
237
+ except Exception as exc:
238
+ _record("error", {"message": str(exc)})
239
+ raise
240
+ _record("response", _to_jsonable(result))
241
+ return result
152
242
 
153
243
  return wrapper
154
244
 
@@ -11,9 +11,9 @@ export const provider = {
11
11
  id: 'python-fastapi',
12
12
  title: 'Python / FastAPI / SQLModel',
13
13
  requiresCapabilities: ['resource.fetch'],
14
- // G4 follow-up (D-handles-providers): this provider now generates a real recover()
15
- // lifecycle + sbf_handle/sbf_handle_snapshot migration, mirroring java-spring's own O4 work --
16
- // the EXCLUDED reasoning that used to justify an empty outputs.spec here is stale.
14
+ // D-write-safety-phase0 (item 1): mirrors java-spring.mjs's own updated comment exactly -- kept
15
+ // unchanged rather than emptied. See that file for the full reasoning (checkArtifacts()'s S6
16
+ // safety net for a `handles ran` but manifest-less state still needs this declared).
17
17
  outputs: { spec: ['handles/migration.sql'] },
18
18
  plan,
19
19
  emit(args) {
@@ -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,
@@ -95,34 +152,66 @@ export function emitTypeScriptExpress({ repoRoot, featureId, plan, resourceFilte
95
152
  },
96
153
  } : null;
97
154
 
98
- const result = emitUnits({ repoRoot, featureId, provider: 'typescript-express', force, reason, infraUnits, resolverUnits, orphanScan, dryRun, computeDiff });
155
+ const result = emitUnits({
156
+ repoRoot, featureId, provider: 'typescript-express', force, reason, infraUnits, resolverUnits, orphanScan, dryRun, computeDiff,
157
+ // D-patch-transactions (Continued): the resolvers barrel's own import list is regenerated
158
+ // from the resolvers directory's REAL current contents (not just this run's own
159
+ // resolverUnits) -- an orphaned resolver from a different feature/module (O2's "never
160
+ // delete, only report" policy leaves it on disk) still needs its own `register(...)` call
161
+ // imported, or that resource type silently stops being servable. `render()` is called by
162
+ // emitUnits() itself AFTER its resolver loop writes this run's own files, so this always
163
+ // sees the final on-disk listing -- now conflict-safe/manifest-tracked like every other
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
+ },
185
+ },
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
+ ],
192
+ });
99
193
 
100
- // The resolvers barrel's own import list is regenerated from the resolvers directory's REAL
101
- // current contents (not just this run's own resolverUnits) -- an orphaned resolver from a
102
- // different feature/module (O2's "never delete, only report" policy leaves it on disk) still
103
- // needs its own `register(...)` call imported, or that resource type silently stops being
104
- // servable. Unconditional, like migration.sql is for java-spring -- never manifest-tracked.
105
- if (!dryRun) {
106
- fs.mkdirSync(resolversDir, { recursive: true });
107
- }
108
- const currentResolverFiles = fs.existsSync(resolversDir)
109
- ? fs.readdirSync(resolversDir).filter((f) => f.endsWith('.ts') && f !== 'resolvers_index.ts').sort()
110
- : [];
111
- const imports = currentResolverFiles.map((f) => `import './${f.replace(/\.ts$/, '')}';`).join('\n');
112
- const resolversIndexContent = render(RESOLVERS_INDEX_TEMPLATE, { IMPORTS: imports });
113
- const resolversIndexRelPath = path.relative(repoRoot, resolversIndexPath);
114
- const resolversIndexDiskContent = fs.existsSync(resolversIndexPath) ? fs.readFileSync(resolversIndexPath, 'utf8') : null;
115
- const resolversIndexAction = resolversIndexDiskContent === null ? 'create' : (resolversIndexDiskContent === resolversIndexContent ? 'unchanged' : 'update');
116
- if (!dryRun && resolversIndexAction !== 'unchanged') {
117
- fs.writeFileSync(resolversIndexPath, resolversIndexContent);
118
- result.written.push(resolversIndexRelPath);
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
+ }
119
210
  }
120
- result.actions.push({ path: resolversIndexRelPath, kind: 'spec', action: resolversIndexAction });
121
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
  }
@@ -0,0 +1,102 @@
1
+ // D-runtime-conformance-receipts: the emit-side half of opt-in runtime contract-conformance
2
+ // checking for typescript-express. Mirrors handles/providers/python-fastapi/observe.mjs's own
3
+ // shape -- observe and handles are orthogonal capabilities that happen to share the same repo-wide
4
+ // "generated infra" pattern, not the same feature. See DECISIONS.md for the full WHY, including the
5
+ // TS-specific response-body-capture note (res.json patch + res.on('finish', ...), never a wrapped
6
+ // return value the way java's @Around/python's `await fn(...)` are) the generated
7
+ // observeContract.ts itself implements.
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { emitUnits, unifiedDiff } from '../../_engine.mjs';
12
+ import { sha256File } from '../../../lib/fsutil.mjs';
13
+ import { specPath } from '../../../lib/paths.mjs';
14
+ import { projectOperation } from '../../observe-schema-projection.mjs';
15
+
16
+ const PROVIDER_ROOT = path.dirname(fileURLToPath(import.meta.url));
17
+ const TEMPLATES_DIR = path.join(PROVIDER_ROOT, 'templates');
18
+
19
+ // Repo-wide, shared across every feature that ever runs `bskel observe emit` -- observedSchema.ts
20
+ // discovers every schemas/*.observed-schema.json file at MODULE LOAD time rather than being
21
+ // regenerated per feature, so these three files are true infra (create-once-per-repo, all-or-nothing
22
+ // conflict unit), same treatment INFRA_FILES gives handles' own handlesDir infra files (codec.ts/
23
+ // registry.ts/router.ts). Three files, not python's four -- TS/Node has no __init__.py-equivalent
24
+ // package marker a plain relative-import directory needs to be importable, so there is no
25
+ // __init__.ts-shaped file to carry over. None of the three need any {{VAR}} substitution -- observe
26
+ // stays decoupled from handles/, cross-imports between them are relative `from './contractCheck'`.
27
+ const INFRA_FILES = [
28
+ { template: 'contractCheck.ts.tmpl', target: 'contractCheck.ts' },
29
+ { template: 'observedSchema.ts.tmpl', target: 'observedSchema.ts' },
30
+ { template: 'observeContract.ts.tmpl', target: 'observeContract.ts' },
31
+ ];
32
+
33
+ function render(templatePath, vars) {
34
+ let content = fs.readFileSync(templatePath, 'utf8');
35
+ for (const [key, value] of Object.entries(vars)) {
36
+ content = content.replaceAll(`{{${key}}}`, String(value));
37
+ }
38
+ return content;
39
+ }
40
+
41
+ function writeUnit(target, content) {
42
+ fs.mkdirSync(path.dirname(target), { recursive: true });
43
+ fs.writeFileSync(target, content);
44
+ }
45
+
46
+ // See DECISIONS.md D-runtime-conformance-receipts. `contract` is the already-loaded, already
47
+ // schema-validated feature contract (bin/bskel.mjs's loadContract) -- this function does not read
48
+ // specs/ itself. `plan` is the already-computed typescript-express resource plan (bin/bskel.mjs
49
+ // calls planTypeScriptExpress() before this, the same --module dependency python-fastapi's own
50
+ // observe emit already established) -- only `plan.srcRoot` is used here, `plan.resources`/
51
+ // `plan.notes` are computed and simply unused, same tolerance emitUnits() already extends to
52
+ // java's/python's own `resolverUnits: []`. There is no per-resource generated file here (a human
53
+ // inserts observeContract('...') directly into an existing route's own middleware array), so
54
+ // emitUnits()'s resolver/orphan machinery has nothing to do.
55
+ export function emitObserveTypeScriptExpress({ repoRoot, featureId, contract, plan, force = false, reason = '', dryRun = false, computeDiff = false }) {
56
+ const observeDir = path.join(plan.srcRoot, 'observe');
57
+
58
+ const infraUnits = INFRA_FILES.map((f) => ({
59
+ id: f.template,
60
+ templatePath: path.join(TEMPLATES_DIR, f.template),
61
+ targetAbs: path.join(observeDir, f.target),
62
+ rendered: render(path.join(TEMPLATES_DIR, f.template), {}),
63
+ }));
64
+
65
+ const result = emitUnits({ repoRoot, featureId, provider: 'typescript-express', force, reason, infraUnits, resolverUnits: [], orphanScan: null, dryRun, computeDiff });
66
+
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.
74
+ const operations = {};
75
+ for (const [opId, opContract] of Object.entries(contract.operations)) {
76
+ operations[opId] = projectOperation(opContract);
77
+ }
78
+ const contractRef = sha256File(specPath(repoRoot, featureId, 'contracts', `${featureId}.schema.json`));
79
+ const schemaContent = `${JSON.stringify({ sbf_observed_schema: '1', feature_id: featureId, feature_uid: contract.feature_uid, contract_ref: contractRef, operations }, null, '\t')}\n`;
80
+ const schemaPath = path.join(observeDir, 'schemas', `${featureId}.observed-schema.json`);
81
+ const schemaRelPath = path.relative(repoRoot, schemaPath);
82
+ const schemaDiskContent = fs.existsSync(schemaPath) ? fs.readFileSync(schemaPath, 'utf8') : null;
83
+ const schemaAction = schemaDiskContent === null ? 'create' : (schemaDiskContent === schemaContent ? 'unchanged' : 'update');
84
+ if (!dryRun) writeUnit(schemaPath, schemaContent);
85
+ result.written.push(schemaRelPath);
86
+ const schemaActionEntry = { path: schemaRelPath, kind: 'spec', action: schemaAction };
87
+ if (computeDiff && schemaAction === 'update') schemaActionEntry.diff = unifiedDiff(schemaRelPath, schemaDiskContent, schemaContent);
88
+ result.actions.push(schemaActionEntry);
89
+
90
+ return {
91
+ ...result,
92
+ postEmitNotes: [
93
+ 'NOT done automatically: route observeContract\'s own receipt sink (defaults to one JSON line per receipt via process.stdout.write) to wherever you want receipt lines collected -- bskel never edits your logging/process-supervisor config. Override it at app startup ("import { setReceiptSink } from \'./observe/observeContract\';") to point it at your own log pipeline, then point `bskel observe import --receipts <path>` at whatever that ends up as.',
94
+ 'If your build compiles TypeScript to a separate output directory (`tsc --outDir dist`), make sure observe/schemas/*.json is copied alongside the compiled observedSchema.js -- tsc only compiles .ts files, it does not copy plain data files into the output tree, and observedSchema.ts discovers its schemas relative to its OWN compiled location at runtime (__dirname). A target app that only ever runs from src/ (ts-node, tsx) needs no extra step here.',
95
+ `Contract-conformance checking only covers path params always, plus a bounded slice of request/response/error body shape -- and only when this contract was emitted with --openapi-file. See the emitted ${path.relative(repoRoot, schemaPath)}'s own "unsupported" markers for exactly what is skipped for this feature.`,
96
+ 'NOT done automatically: insert observeContract(\'<operationId>\') into whichever existing route\'s own middleware array/argument list you want observed (e.g. `router.get(path, [checkJwt, observeContract(\'op-id\')], handler)`) -- nothing is inserted for you (D-resolver-scope: never guess which route implements which operation).',
97
+ 'error_class is never populated in this provider\'s receipts (always omitted) -- Express middleware runs BEFORE the route handler and is structurally unable to observe a thrown error the way java\'s @Around/python\'s except block can (by the time a handler throws or calls next(err), this middleware\'s own call frame has already returned). See DECISIONS.md D-runtime-conformance-receipts.',
98
+ 'Response-body checking only covers a handler that calls res.json(...) or res.send(<object>) (Express\'s own res.send delegates to res.json for a plain-object body) -- a handler that calls res.send(<string>)/res.end(...) directly, or whose response is produced by Express\'s own default/generic error handler, has its response check silently skipped, never guessed.',
99
+ 'OpenAPI reconciliation for this adapter matches scanned Express route strings EXACTLY against the OpenAPI document\'s own path keys (contracts/openapi.mjs has no ":id" <-> "{id}" translation) -- a real, standards-compliant OpenAPI document (which must use "{id}") will not match a scanned ":id"/":id([0-9]+)" route unless the document\'s own path key happens to already read that way. Unlike python-fastapi, this is not "for free."',
100
+ ],
101
+ };
102
+ }
@@ -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
  }