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.
- package/README.md +185 -13
- package/bin/bskel.mjs +689 -33
- package/contracts/emit.mjs +5 -1
- package/contracts/export.mjs +65 -7
- package/contracts/openapi.mjs +321 -30
- package/contracts/validate.mjs +23 -4
- package/handles/_engine.mjs +123 -30
- 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 +73 -16
- package/handles/providers/java-spring/templates/HandleCodec.java.tmpl +19 -1
- package/handles/providers/java-spring/templates/HandleController.java.tmpl +19 -9
- 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 +36 -9
- 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 +129 -39
- package/handles/providers/python-fastapi.mjs +3 -3
- package/handles/providers/typescript-express/emit.mjs +144 -55
- package/handles/providers/typescript-express/observe.mjs +102 -0
- package/handles/providers/typescript-express/templates/codec.ts.tmpl +13 -3
- package/handles/providers/typescript-express/templates/contractCheck.ts.tmpl +136 -0
- 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/observeContract.ts.tmpl +146 -0
- package/handles/providers/typescript-express/templates/observedSchema.ts.tmpl +116 -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/attest.mjs +40 -0
- package/lib/cli.mjs +136 -5
- package/lib/cross-feature-collisions.mjs +286 -0
- package/lib/diff.mjs +35 -0
- package/lib/exit-codes.mjs +21 -0
- package/lib/fsutil.mjs +7 -2
- package/lib/gate-definitions.mjs +85 -1
- package/lib/gates.mjs +5 -1
- package/lib/http-server.mjs +192 -6
- package/lib/lock.mjs +68 -15
- package/lib/patch-kinds.mjs +52 -0
- package/lib/patch-transactions.mjs +206 -0
- package/lib/serve-ui.html +211 -0
- package/lib/verify.mjs +23 -6
- package/lib/workflow.mjs +31 -3
- package/package.json +8 -2
- package/scanners/adapters/_java-spring-analyzer.mjs +9 -1
- package/scanners/adapters/java-spring.mjs +114 -10
- package/scanners/adapters/javascript-express.mjs +46 -13
- package/scanners/adapters/python-fastapi.mjs +9 -1
- package/scanners/adapters/typescript-express.mjs +19 -2
- package/scanners/db/ddl-apply.mjs +253 -0
- package/scanners/db/introspect.mjs +61 -32
- package/scanners/db/migrations.mjs +73 -18
- package/schemas/cross-feature-report.schema.json +66 -0
- package/schemas/cross-feature-resolution.schema.json +28 -0
- package/schemas/feature-contract.schema.json +3 -3
- package/schemas/gate-attestation.schema.json +22 -0
- package/schemas/gate-export.schema.json +58 -0
- package/schemas/handles-plan.schema.json +2 -0
- package/schemas/oracle-manifest.schema.json +58 -0
- package/schemas/patch-transaction.schema.json +182 -0
- package/schemas/scan-report.schema.json +6 -4
- package/schemas/stack-choice.schema.json +12 -1
- package/schemas/stack-record.schema.json +6 -1
- package/stack/apply.mjs +51 -7
- package/stack/catalog/ngrok.yml +8 -2
- 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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
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)
|
|
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,
|
|
@@ -95,34 +152,66 @@ export function emitTypeScriptExpress({ repoRoot, featureId, plan, resourceFilte
|
|
|
95
152
|
},
|
|
96
153
|
} : null;
|
|
97
154
|
|
|
98
|
-
const result = emitUnits({
|
|
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
|
-
//
|
|
101
|
-
//
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|