backend-skeleton 1.0.0-beta.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/LICENSE +21 -0
- package/README.md +284 -0
- package/bin/bskel.mjs +2384 -0
- package/contracts/completeness.mjs +176 -0
- package/contracts/emit.mjs +287 -0
- package/contracts/export.mjs +325 -0
- package/contracts/openapi.mjs +869 -0
- package/contracts/validate.mjs +147 -0
- package/handles/_engine.mjs +281 -0
- package/handles/codec.mjs +119 -0
- package/handles/conformance.mjs +74 -0
- package/handles/providers/java-spring/ast-bridge.mjs +59 -0
- package/handles/providers/java-spring/ast-helper/build.gradle +34 -0
- package/handles/providers/java-spring/ast-helper/gradle/wrapper/gradle-wrapper.jar +0 -0
- package/handles/providers/java-spring/ast-helper/gradle/wrapper/gradle-wrapper.properties +9 -0
- package/handles/providers/java-spring/ast-helper/gradlew +248 -0
- package/handles/providers/java-spring/ast-helper/gradlew.bat +82 -0
- package/handles/providers/java-spring/ast-helper/settings.gradle +1 -0
- package/handles/providers/java-spring/ast-helper/src/main/java/com/backendskeleton/asthelper/Main.java +178 -0
- package/handles/providers/java-spring/emit.mjs +232 -0
- package/handles/providers/java-spring/patch-strategy.mjs +229 -0
- package/handles/providers/java-spring/plan.mjs +377 -0
- package/handles/providers/java-spring/templates/HandleAspect.java.tmpl +125 -0
- package/handles/providers/java-spring/templates/HandleCodec.java.tmpl +150 -0
- package/handles/providers/java-spring/templates/HandleController.java.tmpl +177 -0
- package/handles/providers/java-spring/templates/HandleRegistry.java.tmpl +107 -0
- package/handles/providers/java-spring/templates/HandleRegistryRepository.java.tmpl +8 -0
- package/handles/providers/java-spring/templates/HandleService.java.tmpl +95 -0
- package/handles/providers/java-spring/templates/HandleSnapshot.java.tmpl +75 -0
- package/handles/providers/java-spring/templates/HandleSnapshotRepository.java.tmpl +20 -0
- package/handles/providers/java-spring/templates/RecordHandleSnapshot.java.tmpl +50 -0
- package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +50 -0
- package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +77 -0
- package/handles/providers/java-spring/templates/migration.sql.tmpl +34 -0
- package/handles/providers/java-spring.mjs +21 -0
- package/handles/providers/python-fastapi/emit.mjs +171 -0
- package/handles/providers/python-fastapi/plan.mjs +186 -0
- package/handles/providers/python-fastapi/templates/__init__.py.tmpl +1 -0
- package/handles/providers/python-fastapi/templates/codec.py.tmpl +122 -0
- package/handles/providers/python-fastapi/templates/handle_service.py.tmpl +96 -0
- package/handles/providers/python-fastapi/templates/migration.sql.tmpl +35 -0
- package/handles/providers/python-fastapi/templates/record_snapshot.py.tmpl +155 -0
- package/handles/providers/python-fastapi/templates/registry.py.tmpl +37 -0
- package/handles/providers/python-fastapi/templates/resolver.py.tmpl +59 -0
- package/handles/providers/python-fastapi/templates/resolvers_init.py.tmpl +13 -0
- package/handles/providers/python-fastapi/templates/router.py.tmpl +140 -0
- package/handles/providers/python-fastapi/templates/tables.py.tmpl +66 -0
- package/handles/providers/python-fastapi.mjs +22 -0
- package/handles/providers/typescript-express/emit.mjs +128 -0
- package/handles/providers/typescript-express/plan.mjs +234 -0
- package/handles/providers/typescript-express/templates/codec.ts.tmpl +116 -0
- package/handles/providers/typescript-express/templates/registry.ts.tmpl +39 -0
- package/handles/providers/typescript-express/templates/resolver.ts.tmpl +55 -0
- package/handles/providers/typescript-express/templates/resolvers_index.ts.tmpl +11 -0
- package/handles/providers/typescript-express/templates/router.ts.tmpl +122 -0
- package/handles/providers/typescript-express.mjs +20 -0
- package/handles/registry.mjs +90 -0
- package/lib/cli.mjs +430 -0
- package/lib/doctor.mjs +200 -0
- package/lib/exit-codes.mjs +67 -0
- package/lib/featureid.mjs +55 -0
- package/lib/featurelifecycle.mjs +205 -0
- package/lib/fsutil.mjs +50 -0
- package/lib/gate-definitions.mjs +293 -0
- package/lib/gates.mjs +263 -0
- package/lib/handles-manifest.mjs +92 -0
- package/lib/lock.mjs +68 -0
- package/lib/patch-approvals.mjs +56 -0
- package/lib/paths.mjs +21 -0
- package/lib/repo.mjs +44 -0
- package/lib/schema-validate.mjs +56 -0
- package/lib/state.mjs +124 -0
- package/lib/template.mjs +35 -0
- package/lib/verify.mjs +206 -0
- package/lib/workflow.mjs +142 -0
- package/new/fastapi.mjs +165 -0
- package/new/index.mjs +62 -0
- package/new/params.mjs +233 -0
- package/new/spring.mjs +198 -0
- package/new/templates/fastapi/README.md +26 -0
- package/new/templates/fastapi/app/__init__.py +0 -0
- package/new/templates/fastapi/app/main.py +8 -0
- package/new/templates/fastapi/gitignore +6 -0
- package/new/templates/fastapi/pyproject.toml +14 -0
- package/package.json +50 -0
- package/scanners/adapters/_express-shared.mjs +238 -0
- package/scanners/adapters/_java-spring-analyzer.mjs +273 -0
- package/scanners/adapters/generic-grep.mjs +128 -0
- package/scanners/adapters/java-spring.mjs +301 -0
- package/scanners/adapters/javascript-express.mjs +422 -0
- package/scanners/adapters/python-fastapi.mjs +348 -0
- package/scanners/adapters/typescript-express.mjs +299 -0
- package/scanners/capabilities.mjs +90 -0
- package/scanners/conformance.mjs +59 -0
- package/scanners/db/introspect.mjs +109 -0
- package/scanners/db/migrations.mjs +126 -0
- package/scanners/index.mjs +281 -0
- package/scanners/registry.mjs +130 -0
- package/scanners/render.mjs +136 -0
- package/scanners/text-util.mjs +8 -0
- package/schemas/adapter.schema.json +23 -0
- package/schemas/agent-envelope.schema.json +21 -0
- package/schemas/contract-resolution.schema.json +28 -0
- package/schemas/feature-contract.schema.json +78 -0
- package/schemas/feature-index.schema.json +25 -0
- package/schemas/feature.schema.json +17 -0
- package/schemas/gate-event.schema.json +19 -0
- package/schemas/handles-plan.schema.json +31 -0
- package/schemas/handles-provider.schema.json +26 -0
- package/schemas/patch-approvals.schema.json +28 -0
- package/schemas/scan-report.schema.json +102 -0
- package/schemas/stack-choice.schema.json +89 -0
- package/schemas/stack-record.schema.json +20 -0
- package/schemas/state.schema.json +43 -0
- package/scripts/preflight-base-ref.sh +226 -0
- package/stack/apply.mjs +159 -0
- package/stack/bootstrap/_lib.sh +73 -0
- package/stack/bootstrap/ngrok.sh +90 -0
- package/stack/catalog/ngrok.yml +63 -0
|
@@ -0,0 +1,35 @@
|
|
|
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
|
+
-- Alembic setup, wrapped in `op.execute(open("migration.sql").read())` 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 provider's own
|
|
6
|
+
-- migration.sql.tmpl, so both providers' generated schemas agree at the DDL level.
|
|
7
|
+
|
|
8
|
+
create table if not exists sbf_handle (
|
|
9
|
+
handle_uid uuid primary key,
|
|
10
|
+
kind text not null check (kind in ('r', 'f', 'o')),
|
|
11
|
+
resource_type text not null,
|
|
12
|
+
resource_uid uuid not null,
|
|
13
|
+
pointer text,
|
|
14
|
+
feature_uid uuid not null,
|
|
15
|
+
operation_id text,
|
|
16
|
+
contract_ref text not null,
|
|
17
|
+
created_at timestamptz not null default now(),
|
|
18
|
+
revoked_at timestamptz,
|
|
19
|
+
revoked_reason text,
|
|
20
|
+
unique (resource_type, resource_uid, pointer)
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
create index if not exists ix_sbf_handle_resource on sbf_handle (resource_type, resource_uid);
|
|
24
|
+
|
|
25
|
+
create table if not exists sbf_handle_snapshot (
|
|
26
|
+
snapshot_id bigserial primary key,
|
|
27
|
+
handle_uid uuid not null references sbf_handle (handle_uid),
|
|
28
|
+
envelope_dir text not null check (envelope_dir in ('request', 'response', 'error')),
|
|
29
|
+
operation_id text not null,
|
|
30
|
+
contract_hash text not null,
|
|
31
|
+
payload jsonb not null,
|
|
32
|
+
recorded_at timestamptz not null default now()
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
create index if not exists ix_sbf_handle_snapshot_handle_uid on sbf_handle_snapshot (handle_uid, recorded_at desc);
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Generated by backend-skeleton. Do not hand-edit -- change the source template and regenerate.
|
|
2
|
+
|
|
3
|
+
G4 follow-up (D-handles-providers): the opt-in AUTOMATIC half of the handle lifecycle -- apply
|
|
4
|
+
`@record_snapshot(...)` to an EXISTING service function (never generated onto one -- a human
|
|
5
|
+
decides which functions are worth handle-tracking) to have it automatically register the
|
|
6
|
+
resource-level handle and record the request/response/error envelope around every call.
|
|
7
|
+
|
|
8
|
+
Combines Java's separate `@RecordHandleSnapshot` annotation + `HandleAspect` interceptor into ONE
|
|
9
|
+
file/decorator, deliberately -- Python decorators natively ARE this ecosystem's method-
|
|
10
|
+
interception mechanism, so there is no separate "declare a marker" vs. "implement the
|
|
11
|
+
interceptor" split to preserve; splitting this into two files would be cargo-culting Java's file
|
|
12
|
+
count, not mirroring an invariant.
|
|
13
|
+
|
|
14
|
+
`resource_uid_param`/`session_param` are explicit parameter NAMES (not positional indices --
|
|
15
|
+
resolved via inspect.signature(fn).bind(...), so the SAME function can be called positionally or
|
|
16
|
+
by keyword by different callers without silently misattributing a snapshot to the wrong resource,
|
|
17
|
+
which a bare `args[index]` would do for any keyword call). `redact` is an explicit array of JSON
|
|
18
|
+
Pointers, never a guessed heuristic. A failure recording a snapshot is ALWAYS logged and
|
|
19
|
+
swallowed, never allowed to fail the real business call it wraps -- snapshot recording is
|
|
20
|
+
best-effort observability, not a new way for an unrelated call to start failing.
|
|
21
|
+
|
|
22
|
+
Requires nothing extra installed (unlike Java's spring-boot-starter-aop) -- Python decorators need
|
|
23
|
+
no framework support -- but still requires a human to apply it to their own code; codegen never
|
|
24
|
+
touches an existing business logic file.
|
|
25
|
+
|
|
26
|
+
Example:
|
|
27
|
+
@record_snapshot(resource_type="Organization", operation_id="update_organization",
|
|
28
|
+
resource_uid_param="organization_id", session_param="session",
|
|
29
|
+
redact=["/internal_note"])
|
|
30
|
+
def update_organization(session: Session, organization_id: uuid.UUID, request: UpdateOrganizationRequest) -> OrganizationResponse:
|
|
31
|
+
...
|
|
32
|
+
"""
|
|
33
|
+
import functools
|
|
34
|
+
import inspect
|
|
35
|
+
import logging
|
|
36
|
+
import uuid
|
|
37
|
+
|
|
38
|
+
from {{PKG}}.handles import handle_service
|
|
39
|
+
from {{PKG}}.handles.codec import derive_handle_uid
|
|
40
|
+
from {{PKG}}.handles.registry import resolver_for
|
|
41
|
+
|
|
42
|
+
logger = logging.getLogger(__name__)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _redact(obj, pointer: str) -> None:
|
|
46
|
+
"""Walks to `pointer`'s parent container and blanks the leaf value in place -- genuinely
|
|
47
|
+
simpler than Java's hand-rolled ObjectNode navigator, since Python containers are natively
|
|
48
|
+
mutable (no read-only-.at()-style limitation to work around). Same ~1/~0 unescaping.
|
|
49
|
+
"""
|
|
50
|
+
if not pointer or not pointer.startswith("/"):
|
|
51
|
+
return
|
|
52
|
+
parts = [p.replace("~1", "/").replace("~0", "~") for p in pointer[1:].split("/")]
|
|
53
|
+
current = obj
|
|
54
|
+
for part in parts[:-1]:
|
|
55
|
+
if isinstance(current, dict) and part in current:
|
|
56
|
+
current = current[part]
|
|
57
|
+
elif isinstance(current, list) and part.lstrip("-").isdigit() and int(part) < len(current):
|
|
58
|
+
current = current[int(part)]
|
|
59
|
+
else:
|
|
60
|
+
return
|
|
61
|
+
leaf = parts[-1]
|
|
62
|
+
if isinstance(current, dict) and leaf in current:
|
|
63
|
+
current[leaf] = "***REDACTED***"
|
|
64
|
+
elif isinstance(current, list) and leaf.lstrip("-").isdigit() and int(leaf) < len(current):
|
|
65
|
+
current[int(leaf)] = "***REDACTED***"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _request_payload(bound: inspect.BoundArguments, resource_uid_param: str, session_param: str):
|
|
69
|
+
"""The remaining arguments once the resource-uid/session ones are excluded -- the sole
|
|
70
|
+
survivor UNWRAPPED (the common case: one DTO), matching Java's own HandleAspect#requestPayload
|
|
71
|
+
exactly, so a `redact` pointer written against the DTO's own fields (e.g. "/internal_note")
|
|
72
|
+
resolves correctly. A real bug this design initially had and a real functional test caught:
|
|
73
|
+
an earlier draft always wrapped in `{param_name: value}`, so "/internal_note" silently never
|
|
74
|
+
matched anything (the real shape was `{"request": {"internal_note": ...}}`) -- redaction
|
|
75
|
+
looked like it worked (no error) but never actually redacted anything in the common
|
|
76
|
+
single-DTO case. Falls back to a dict keyed by parameter name (not Java's positional list --
|
|
77
|
+
Python's bound arguments already carry names) only when more than one non-uid/session
|
|
78
|
+
parameter remains.
|
|
79
|
+
"""
|
|
80
|
+
rest = {k: _to_jsonable(v) for k, v in bound.arguments.items() if k not in (resource_uid_param, session_param)}
|
|
81
|
+
if len(rest) == 1:
|
|
82
|
+
return next(iter(rest.values()))
|
|
83
|
+
return rest
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _to_jsonable(value):
|
|
87
|
+
"""Best-effort plain-dict/list/scalar projection -- a Pydantic/SQLModel object's own
|
|
88
|
+
`.model_dump(mode="json")`, or the value as-is if it's already a plain JSON-shaped value
|
|
89
|
+
(matches the payload shapes this decorator actually receives: request DTOs, response models,
|
|
90
|
+
or a plain {"message": ...} error dict).
|
|
91
|
+
"""
|
|
92
|
+
if hasattr(value, "model_dump"):
|
|
93
|
+
return value.model_dump(mode="json")
|
|
94
|
+
return value
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _safely(action, handle_uid, envelope_dir: str) -> None:
|
|
98
|
+
"""Best-effort: any failure here is logged, never propagated -- see this module's own
|
|
99
|
+
docstring for why.
|
|
100
|
+
"""
|
|
101
|
+
try:
|
|
102
|
+
action()
|
|
103
|
+
except Exception:
|
|
104
|
+
logger.warning("record_snapshot: could not record %s snapshot for handle %s -- the wrapped call proceeds unaffected", envelope_dir, handle_uid, exc_info=True)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def record_snapshot(*, resource_type: str, operation_id: str, resource_uid_param: str, session_param: str, redact: list[str] | None = None):
|
|
108
|
+
redact = redact or []
|
|
109
|
+
|
|
110
|
+
def decorator(fn):
|
|
111
|
+
signature = inspect.signature(fn)
|
|
112
|
+
|
|
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
|
|
152
|
+
|
|
153
|
+
return wrapper
|
|
154
|
+
|
|
155
|
+
return decorator
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Generated by backend-skeleton. Do not hand-edit -- change the source template and regenerate.
|
|
2
|
+
|
|
3
|
+
In-process registry mapping a handle `type` string to its resolver instance -- deliberately NOT a
|
|
4
|
+
database table. NOT the same concept as the `HandleRegistry` DB table in `tables.py` (G4
|
|
5
|
+
follow-up, D-handles-providers) -- that table backs a real `GET /handles/{handle}/recover`;
|
|
6
|
+
this dict is pure in-process dispatch, no persistence at all. The names collide only because
|
|
7
|
+
Java's own naming does; treat them as unrelated.
|
|
8
|
+
"""
|
|
9
|
+
from typing import Protocol, runtime_checkable
|
|
10
|
+
from uuid import UUID
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@runtime_checkable
|
|
14
|
+
class ResourceResolver(Protocol):
|
|
15
|
+
type: str
|
|
16
|
+
# G4 follow-up: baked in at `bskel handles emit` time (regenerated every run, so a contract
|
|
17
|
+
# change is picked up automatically), never read from disk at runtime. handle_service.register
|
|
18
|
+
# stores contract_ref as a registry row's contract_ref; `recover`'s schema_drift check compares
|
|
19
|
+
# a snapshot's own recorded hash against the CURRENT value of this attribute, not a stale one.
|
|
20
|
+
contract_ref: str
|
|
21
|
+
feature_uid: UUID
|
|
22
|
+
|
|
23
|
+
def fetch(self, session, resource_uid): ...
|
|
24
|
+
def check_access(self, session, obj) -> None: ...
|
|
25
|
+
def patch_field(self, session, obj, pointer, value) -> None: ...
|
|
26
|
+
def to_public(self, obj): ...
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
_RESOLVERS: dict[str, "ResourceResolver"] = {}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def register(resolver: "ResourceResolver") -> None:
|
|
33
|
+
_RESOLVERS[resolver.type] = resolver
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def resolver_for(type_: str) -> "ResourceResolver | None":
|
|
37
|
+
return _RESOLVERS.get(type_)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Generated by backend-skeleton (bskel handles emit) for feature {{FEATURE_ID}}.
|
|
2
|
+
type = "{{RESOURCE_TYPE}}"
|
|
3
|
+
|
|
4
|
+
`fetch`/`to_public` are wired to real, existing code: `fetch` uses SQLModel's `session.get(...)`,
|
|
5
|
+
this stack's own canonical read path, and `to_public` projects through {{PUBLIC_MODEL}} -- required
|
|
6
|
+
so a generic handle-fetch route can never accidentally serialize a column the app does not
|
|
7
|
+
otherwise expose (a real example this provider was built against: a table model carrying a
|
|
8
|
+
password hash column with no `response_model=`-shaped protection of its own). See the fetch route
|
|
9
|
+
this was planned from: {{FETCH_ROUTE_FILE}}:{{FETCH_ROUTE_LINE}}.
|
|
10
|
+
|
|
11
|
+
`check_access` is DELIBERATELY a fail-closed stub, not auto-generated business logic. FastAPI has
|
|
12
|
+
no imperatively-readable global security context the way Spring's SecurityContextHolder is, and
|
|
13
|
+
this stack's own real authorization logic lives inside route function bodies (per-row checks),
|
|
14
|
+
which a static source scan cannot safely extract. Wire it to whatever this app's own current-
|
|
15
|
+
user/permission pattern is before relying on it -- until then, every request is denied.
|
|
16
|
+
|
|
17
|
+
`patch_field` is DELIBERATELY a stub for the same reason Java's ResourceResolverStub.java.tmpl's
|
|
18
|
+
patchField() is: this stack's own update conventions must be checked by a human before writing to
|
|
19
|
+
them (see D-resolver-scope in DECISIONS.md) -- do not write directly against the ORM, that
|
|
20
|
+
bypasses this app's existing validation and business rules.
|
|
21
|
+
"""
|
|
22
|
+
import uuid
|
|
23
|
+
|
|
24
|
+
from fastapi import HTTPException
|
|
25
|
+
|
|
26
|
+
from {{MODEL_IMPORT}} import {{MODEL}}, {{PUBLIC_MODEL}}
|
|
27
|
+
from {{PKG}}.handles.registry import register
|
|
28
|
+
|
|
29
|
+
# G4 follow-up (D-handles-providers): baked in at `bskel handles emit` time, mirroring Java's
|
|
30
|
+
# ResourceResolverStub.java.tmpl exactly -- see registry.py's own ResourceResolver.contract_ref/
|
|
31
|
+
# feature_uid docstring for what these back.
|
|
32
|
+
CONTRACT_REF = "{{CONTRACT_REF}}"
|
|
33
|
+
FEATURE_UID = uuid.UUID("{{FEATURE_UID}}")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class {{RESOURCE_TYPE}}Resolver:
|
|
37
|
+
type = "{{RESOURCE_TYPE}}"
|
|
38
|
+
contract_ref = CONTRACT_REF
|
|
39
|
+
feature_uid = FEATURE_UID
|
|
40
|
+
|
|
41
|
+
def fetch(self, session, resource_uid):
|
|
42
|
+
return session.get({{MODEL}}, resource_uid)
|
|
43
|
+
|
|
44
|
+
def check_access(self, session, obj) -> None:
|
|
45
|
+
# TODO: wire this app's own current-user/permission check before relying on this resolver.
|
|
46
|
+
# Fails closed until then -- every request is denied, not silently permitted.
|
|
47
|
+
raise HTTPException(status_code=403, detail="access check not yet implemented for {{RESOURCE_TYPE}}")
|
|
48
|
+
|
|
49
|
+
def patch_field(self, session, obj, pointer, value) -> None:
|
|
50
|
+
# TODO: route through this app's real update path for {{MODEL}}, matching whichever
|
|
51
|
+
# partial-update convention its own schemas actually use. Do not write directly to `obj`'s
|
|
52
|
+
# attributes and commit -- that bypasses this app's existing validation/business rules.
|
|
53
|
+
raise HTTPException(status_code=501, detail=f"patch_field not yet implemented for {{RESOURCE_TYPE}}{pointer}")
|
|
54
|
+
|
|
55
|
+
def to_public(self, obj):
|
|
56
|
+
return {{PUBLIC_MODEL}}.model_validate(obj)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
register({{RESOURCE_TYPE}}Resolver())
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Generated by backend-skeleton. Do not hand-edit -- change the source template and regenerate.
|
|
2
|
+
|
|
3
|
+
Auto-imports every sibling module in this package so each resolver's own `register(...)` call at
|
|
4
|
+
import time runs exactly once. Keeps this file itself feature-independent (its content never
|
|
5
|
+
depends on which resolvers exist), which is what lets `bskel handles emit` treat it as repo-owned
|
|
6
|
+
infra rather than needing to regenerate it per feature.
|
|
7
|
+
"""
|
|
8
|
+
import importlib
|
|
9
|
+
import pkgutil
|
|
10
|
+
|
|
11
|
+
for _mod in pkgutil.iter_modules(__path__):
|
|
12
|
+
if not _mod.name.startswith("_"):
|
|
13
|
+
importlib.import_module(f"{__name__}.{_mod.name}")
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Generated by backend-skeleton.
|
|
2
|
+
|
|
3
|
+
D-handles (DECISIONS.md): exposed in production. Per-resolver `check_access()` is the entire
|
|
4
|
+
defense for this generic-object-accessor security surface -- see the docstring on each generated
|
|
5
|
+
resolver for why it is ALWAYS a fail-closed stub in this provider (FastAPI has no imperatively
|
|
6
|
+
readable global security context the way Spring's SecurityContextHolder is, and this app's own
|
|
7
|
+
real authorization logic lives inside route function bodies, not decorators -- a static source
|
|
8
|
+
scan cannot safely extract it).
|
|
9
|
+
"""
|
|
10
|
+
import uuid
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
|
|
13
|
+
from fastapi import APIRouter, HTTPException, Query
|
|
14
|
+
from sqlmodel import select
|
|
15
|
+
|
|
16
|
+
from {{PKG}}.handles.codec import decode_handle, derive_handle_uid, resolve_json_pointer, MISSING
|
|
17
|
+
from {{PKG}}.handles.registry import resolver_for
|
|
18
|
+
from {{PKG}}.handles.tables import HandleRegistry, HandleSnapshot
|
|
19
|
+
from {{SESSION_DEP_MODULE}} import {{SESSION_DEP_NAME}}
|
|
20
|
+
|
|
21
|
+
router = APIRouter(prefix="/handles", tags=["handles"])
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@router.get("/{handle}")
|
|
25
|
+
def fetch_handle(handle: str, session: {{SESSION_DEP_NAME}}):
|
|
26
|
+
try:
|
|
27
|
+
decoded = decode_handle(handle)
|
|
28
|
+
except ValueError as exc:
|
|
29
|
+
raise HTTPException(status_code=400, detail=str(exc))
|
|
30
|
+
|
|
31
|
+
resolver = resolver_for(decoded.type)
|
|
32
|
+
if resolver is None:
|
|
33
|
+
raise HTTPException(status_code=404, detail=f'no resolver registered for handle type "{decoded.type}"')
|
|
34
|
+
|
|
35
|
+
obj = resolver.fetch(session, decoded.uuid)
|
|
36
|
+
if obj is None:
|
|
37
|
+
raise HTTPException(status_code=404, detail="resource not found")
|
|
38
|
+
resolver.check_access(session, obj)
|
|
39
|
+
public = resolver.to_public(obj)
|
|
40
|
+
if decoded.pointer is None:
|
|
41
|
+
return public
|
|
42
|
+
|
|
43
|
+
# G4 follow-up (D-handles-providers): walks the PUBLIC projection, not the raw `fetch()`
|
|
44
|
+
# row -- a deliberate, security-preserving departure from Java's own literal code shape, not
|
|
45
|
+
# a weakening. Java's fetch() safely returns a walkable object because it already delegates
|
|
46
|
+
# to the resource's real response DTO; this provider's own fetch() returns the raw ORM row by
|
|
47
|
+
# original G4 design (to_public() is a SEPARATE, required projection specifically because a
|
|
48
|
+
# table can carry columns -- e.g. a password hash -- with no protection besides each route's
|
|
49
|
+
# own response_model=). Walking the raw row here would silently reopen exactly the column-leak
|
|
50
|
+
# vector to_public() exists to close (a crafted field handle with pointer "/hashed_password").
|
|
51
|
+
root = public.model_dump(mode="json") if hasattr(public, "model_dump") else public
|
|
52
|
+
target = resolve_json_pointer(root, decoded.pointer)
|
|
53
|
+
if target is MISSING:
|
|
54
|
+
raise HTTPException(status_code=404, detail=f'pointer "{decoded.pointer}" does not resolve on {decoded.type}')
|
|
55
|
+
return target
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@router.patch("/{handle}", status_code=204)
|
|
59
|
+
def patch_handle(handle: str, session: {{SESSION_DEP_NAME}}, value: dict):
|
|
60
|
+
try:
|
|
61
|
+
decoded = decode_handle(handle)
|
|
62
|
+
except ValueError as exc:
|
|
63
|
+
raise HTTPException(status_code=400, detail=str(exc))
|
|
64
|
+
|
|
65
|
+
# D-security-10 parity: checks kind explicitly, not just pointer-presence -- mirrors
|
|
66
|
+
# HandleController.java.tmpl's identical check.
|
|
67
|
+
if decoded.kind != "f" or decoded.pointer is None:
|
|
68
|
+
raise HTTPException(status_code=400, detail="cannot PATCH a resource-level handle (kind=r) -- only field handles (kind=f) support PATCH")
|
|
69
|
+
|
|
70
|
+
resolver = resolver_for(decoded.type)
|
|
71
|
+
if resolver is None:
|
|
72
|
+
raise HTTPException(status_code=404, detail=f'no resolver registered for handle type "{decoded.type}"')
|
|
73
|
+
|
|
74
|
+
obj = resolver.fetch(session, decoded.uuid)
|
|
75
|
+
if obj is None:
|
|
76
|
+
raise HTTPException(status_code=404, detail="resource not found")
|
|
77
|
+
resolver.check_access(session, obj)
|
|
78
|
+
resolver.patch_field(session, obj, decoded.pointer, value)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# G4 follow-up (D-handles-providers): mirrors HandleController.recover() including its full
|
|
82
|
+
# D-security-9 cross-check -- never weakened. handle_uid alone does not prove WHAT is being
|
|
83
|
+
# recovered (kind="r"'s derivation returns the resource UUID verbatim, no type binding baked into
|
|
84
|
+
# the hash), so an attacker who controls `type` in the token (as long as it names a real,
|
|
85
|
+
# registered resolver whose check_access() they can pass) could otherwise request the snapshot
|
|
86
|
+
# history of a DIFFERENT, more sensitive resource type sharing the same UUID. Fix: cross-check the
|
|
87
|
+
# decoded type/kind/pointer against the registry row this handle_uid was actually registered
|
|
88
|
+
# under, and 404 on ANY disagreement -- without saying which field mismatched, so the error can't
|
|
89
|
+
# be used to probe which part was wrong. A revoked handle is rejected the same way.
|
|
90
|
+
#
|
|
91
|
+
# Ecosystem-honest gap, not silently resolved: Java's role check is resource-existence-independent
|
|
92
|
+
# (recovering history of a since-deleted resource is legitimate), so it never calls fetch() at
|
|
93
|
+
# all before the registry check. This provider's own check_access(session, obj) is deliberately
|
|
94
|
+
# ROW-level (every shipped resolver's check_access already denies unconditionally regardless of
|
|
95
|
+
# obj, so calling fetch() first and passing obj=None through for a deleted resource is safe
|
|
96
|
+
# today) -- but a human wiring a real check_access() must handle obj=None explicitly, or recover()
|
|
97
|
+
# can never recall history for a resource that no longer exists.
|
|
98
|
+
@router.get("/{handle}/recover")
|
|
99
|
+
def recover_handle(handle: str, session: {{SESSION_DEP_NAME}}, at: datetime | None = Query(default=None)):
|
|
100
|
+
try:
|
|
101
|
+
decoded = decode_handle(handle)
|
|
102
|
+
except ValueError as exc:
|
|
103
|
+
raise HTTPException(status_code=400, detail=str(exc))
|
|
104
|
+
|
|
105
|
+
resolver = resolver_for(decoded.type)
|
|
106
|
+
if resolver is None:
|
|
107
|
+
raise HTTPException(status_code=404, detail=f'no resolver registered for handle type "{decoded.type}"')
|
|
108
|
+
|
|
109
|
+
obj = resolver.fetch(session, decoded.uuid)
|
|
110
|
+
resolver.check_access(session, obj)
|
|
111
|
+
|
|
112
|
+
handle_uid = uuid.UUID(derive_handle_uid(decoded.kind, decoded.type, decoded.uuid, decoded.pointer))
|
|
113
|
+
registry = session.get(HandleRegistry, handle_uid)
|
|
114
|
+
if (
|
|
115
|
+
registry is None
|
|
116
|
+
or registry.resource_type != decoded.type
|
|
117
|
+
or registry.kind != decoded.kind
|
|
118
|
+
or registry.pointer != decoded.pointer
|
|
119
|
+
or registry.is_revoked
|
|
120
|
+
):
|
|
121
|
+
raise HTTPException(status_code=404, detail="no snapshot recorded for this handle")
|
|
122
|
+
|
|
123
|
+
query = select(HandleSnapshot).where(HandleSnapshot.handle_uid == handle_uid)
|
|
124
|
+
if at is not None:
|
|
125
|
+
query = query.where(HandleSnapshot.recorded_at <= at)
|
|
126
|
+
snapshot = session.exec(query.order_by(HandleSnapshot.recorded_at.desc())).first()
|
|
127
|
+
if snapshot is None:
|
|
128
|
+
detail = "no snapshot recorded for this handle" + (f" at or before {at}" if at is not None else "")
|
|
129
|
+
raise HTTPException(status_code=404, detail=detail)
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
"handle": handle,
|
|
133
|
+
"recorded_at": snapshot.recorded_at,
|
|
134
|
+
"operation_id": snapshot.operation_id,
|
|
135
|
+
# snapshot.payload is already a native dict/list/scalar (a real JSONB column) -- no
|
|
136
|
+
# manual re-parse step exists here for the double-encoding bug Java once had to be
|
|
137
|
+
# possible in the first place. See tables.py's own docstring.
|
|
138
|
+
"payload": snapshot.payload,
|
|
139
|
+
"schema_drift": registry.contract_ref != snapshot.contract_hash,
|
|
140
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Generated by backend-skeleton. Do not hand-edit -- change the source template and regenerate.
|
|
2
|
+
|
|
3
|
+
G4 follow-up (D-handles-providers): mirrors Java's HandleRegistry.java.tmpl/HandleSnapshot.java.tmpl
|
|
4
|
+
(sbf_handle/sbf_handle_snapshot) -- same table names, columns, and unique(resource_type,
|
|
5
|
+
resource_uid, pointer) constraint, so both providers' generated schemas agree byte-for-byte at the
|
|
6
|
+
DDL level (see the emitted migration.sql). NOT the same concept as this package's own registry.py
|
|
7
|
+
-- that file is an in-process type->resolver dispatch dict; HandleRegistry here is a real database
|
|
8
|
+
table. The names collide only because Java's own naming does; treat them as unrelated.
|
|
9
|
+
|
|
10
|
+
`HandleSnapshot.payload` is a native `JSONB` column holding a plain dict/list -- SQLAlchemy
|
|
11
|
+
(de)serializes at the DB boundary automatically. There is no manual json.dumps/json.loads step
|
|
12
|
+
anywhere in this provider for `payload` to round-trip through, unlike Java's own `payload` column
|
|
13
|
+
(a manually (de)serialized String) -- the exact bug class that once caused Java's recover() to
|
|
14
|
+
double-JSON-encode a caller's payload (forgetting to re-parse a raw string before re-embedding it)
|
|
15
|
+
is structurally impossible here, not merely avoided by a matching fix.
|
|
16
|
+
"""
|
|
17
|
+
import uuid
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
|
|
20
|
+
from sqlalchemy import Column, UniqueConstraint
|
|
21
|
+
from sqlalchemy.dialects.postgresql import JSONB
|
|
22
|
+
from sqlmodel import Field, SQLModel
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _utcnow() -> datetime:
|
|
26
|
+
return datetime.now(timezone.utc)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class HandleRegistry(SQLModel, table=True):
|
|
30
|
+
__tablename__ = "sbf_handle"
|
|
31
|
+
__table_args__ = (UniqueConstraint("resource_type", "resource_uid", "pointer"),)
|
|
32
|
+
|
|
33
|
+
handle_uid: uuid.UUID = Field(primary_key=True)
|
|
34
|
+
# "r" (resource), "f" (field), or "o" (operation instance -- reserved, unused).
|
|
35
|
+
kind: str
|
|
36
|
+
resource_type: str
|
|
37
|
+
resource_uid: uuid.UUID
|
|
38
|
+
# RFC 6901 JSON Pointer, None for kind="r".
|
|
39
|
+
pointer: str | None = None
|
|
40
|
+
feature_uid: uuid.UUID
|
|
41
|
+
operation_id: str | None = None
|
|
42
|
+
contract_ref: str
|
|
43
|
+
created_at: datetime = Field(default_factory=_utcnow)
|
|
44
|
+
revoked_at: datetime | None = None
|
|
45
|
+
revoked_reason: str | None = None
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def is_revoked(self) -> bool:
|
|
49
|
+
return self.revoked_at is not None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class HandleSnapshot(SQLModel, table=True):
|
|
53
|
+
__tablename__ = "sbf_handle_snapshot"
|
|
54
|
+
|
|
55
|
+
snapshot_id: int | None = Field(default=None, primary_key=True)
|
|
56
|
+
handle_uid: uuid.UUID = Field(foreign_key="sbf_handle.handle_uid")
|
|
57
|
+
# "request", "response", or "error" -- what kind of payload this envelope holds.
|
|
58
|
+
envelope_dir: str
|
|
59
|
+
operation_id: str
|
|
60
|
+
# Hash of the feature contract this snapshot was recorded against. `recover` compares this to
|
|
61
|
+
# the CURRENT contract's hash -- a mismatch means the contract has since changed shape, and
|
|
62
|
+
# `recover` must say so explicitly (a schema_drift marker) rather than silently returning a
|
|
63
|
+
# payload the current contract no longer describes.
|
|
64
|
+
contract_hash: str
|
|
65
|
+
payload: dict | list | str | int | float | bool | None = Field(sa_column=Column(JSONB, nullable=False))
|
|
66
|
+
recorded_at: datetime = Field(default_factory=_utcnow)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { plan } from './python-fastapi/plan.mjs';
|
|
2
|
+
import { emitPythonFastApi } from './python-fastapi/emit.mjs';
|
|
3
|
+
|
|
4
|
+
// D-handles-providers (G4). Zero-registration descriptor loaded by handles/registry.mjs -- see
|
|
5
|
+
// schemas/handles-provider.schema.json for the contract this object's JSON-shaped fields must
|
|
6
|
+
// match (plan/emit are functions, checked separately). The real second codegen provider G1's own
|
|
7
|
+
// D-adapter-registry EXIT held out for before the java-spring extraction (handles/providers/
|
|
8
|
+
// java-spring.mjs) was worth doing at all.
|
|
9
|
+
export const provider = {
|
|
10
|
+
contract: 'sbf.handles-provider/1',
|
|
11
|
+
id: 'python-fastapi',
|
|
12
|
+
title: 'Python / FastAPI / SQLModel',
|
|
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.
|
|
17
|
+
outputs: { spec: ['handles/migration.sql'] },
|
|
18
|
+
plan,
|
|
19
|
+
emit(args) {
|
|
20
|
+
return emitPythonFastApi(args);
|
|
21
|
+
},
|
|
22
|
+
};
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { emitUnits } from '../../_engine.mjs';
|
|
5
|
+
|
|
6
|
+
const PROVIDER_ROOT = path.dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const TEMPLATES_DIR = path.join(PROVIDER_ROOT, 'templates');
|
|
8
|
+
const RESOLVER_TEMPLATE = path.join(TEMPLATES_DIR, 'resolver.ts.tmpl');
|
|
9
|
+
const RESOLVERS_INDEX_TEMPLATE = path.join(TEMPLATES_DIR, 'resolvers_index.ts.tmpl');
|
|
10
|
+
|
|
11
|
+
function render(templatePath, vars) {
|
|
12
|
+
let content = fs.readFileSync(templatePath, 'utf8');
|
|
13
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
14
|
+
content = content.replaceAll(`{{${key}}}`, String(value));
|
|
15
|
+
}
|
|
16
|
+
return content;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// PascalCase -> camelCase filename, good enough for the class names this scanner actually
|
|
20
|
+
// extracts (ASCII identifiers only, same assumption every other provider's own naming makes).
|
|
21
|
+
function camelCase(s) {
|
|
22
|
+
return s.length > 0 ? s[0].toLowerCase() + s.slice(1) : s;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Relative TS import specifier from `fromFile`'s own directory to `toFile`, extension-stripped,
|
|
26
|
+
// forward-slash-joined (Node import specifiers are never OS-path-separated), always explicitly
|
|
27
|
+
// relative (`./x`, not bare `x`) so generated code never depends on a target's own tsconfig
|
|
28
|
+
// `baseUrl` being set a particular way.
|
|
29
|
+
function relativeImportPath(fromFile, toFile) {
|
|
30
|
+
const fromDir = path.dirname(fromFile);
|
|
31
|
+
let rel = path.relative(fromDir, toFile).replace(/\.ts$/, '');
|
|
32
|
+
rel = rel.split(path.sep).join('/');
|
|
33
|
+
if (!rel.startsWith('.')) rel = `./${rel}`;
|
|
34
|
+
return rel;
|
|
35
|
+
}
|
|
36
|
+
|
|
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 }) {
|
|
44
|
+
const handlesDir = path.join(plan.srcRoot, 'handles');
|
|
45
|
+
const resolversDir = path.join(handlesDir, 'resolvers');
|
|
46
|
+
const resolversIndexPath = path.join(resolversDir, 'resolvers_index.ts');
|
|
47
|
+
|
|
48
|
+
const infraUnits = [
|
|
49
|
+
{ 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
|
+
{ 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'), {}) },
|
|
52
|
+
];
|
|
53
|
+
|
|
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 {
|
|
71
|
+
id: 'resolver.ts.tmpl',
|
|
72
|
+
resourceType: resource.type,
|
|
73
|
+
module: plan.module,
|
|
74
|
+
templatePath: RESOLVER_TEMPLATE,
|
|
75
|
+
targetAbs,
|
|
76
|
+
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
|
+
pristineRenderFor: (ownerId) => render(RESOLVER_TEMPLATE, { ...vars, FEATURE_ID: ownerId }),
|
|
82
|
+
};
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const orphanScan = (!resourceFilter && plan.module) ? {
|
|
86
|
+
dir: resolversDir,
|
|
87
|
+
module: plan.module,
|
|
88
|
+
matchesFile: (file) => file.endsWith('.ts') && file !== 'resolvers_index.ts',
|
|
89
|
+
// Filename can't reliably recover the exact class-name casing -- read the `type: 'X'` field
|
|
90
|
+
// the resolver template itself carries instead of guessing from the filename, same content-
|
|
91
|
+
// read approach python-fastapi's own orphan scan already uses.
|
|
92
|
+
resourceTypeOf: (_file, content) => {
|
|
93
|
+
const m = content.match(/^\s*type:\s*'([^']+)'/m);
|
|
94
|
+
return m ? m[1] : null;
|
|
95
|
+
},
|
|
96
|
+
} : null;
|
|
97
|
+
|
|
98
|
+
const result = emitUnits({ repoRoot, featureId, provider: 'typescript-express', force, reason, infraUnits, resolverUnits, orphanScan, dryRun, computeDiff });
|
|
99
|
+
|
|
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);
|
|
119
|
+
}
|
|
120
|
+
result.actions.push({ path: resolversIndexRelPath, kind: 'spec', action: resolversIndexAction });
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
...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
|
+
],
|
|
127
|
+
};
|
|
128
|
+
}
|