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,34 @@
|
|
|
1
|
+
-- Generated by backend-skeleton (bskel handles emit) for feature {{FEATURE_ID}}.
|
|
2
|
+
-- NOT applied automatically -- this repo has no Flyway/Liquibase (confirmed by `bskel scan`'s
|
|
3
|
+
-- Plane A/B during Phase 2), so apply this yourself against Supabase (e.g. via the SQL editor
|
|
4
|
+
-- or `psql`) once you've reviewed it. See D-config-patch / D-migration-scope in DECISIONS.md
|
|
5
|
+
-- for why backend-skeleton never applies schema changes on its own.
|
|
6
|
+
|
|
7
|
+
create table if not exists sbf_handle (
|
|
8
|
+
handle_uid uuid primary key,
|
|
9
|
+
kind text not null check (kind in ('r', 'f', 'o')),
|
|
10
|
+
resource_type text not null,
|
|
11
|
+
resource_uid uuid not null,
|
|
12
|
+
pointer text,
|
|
13
|
+
feature_uid uuid not null,
|
|
14
|
+
operation_id text,
|
|
15
|
+
contract_ref text not null,
|
|
16
|
+
created_at timestamptz not null default now(),
|
|
17
|
+
revoked_at timestamptz,
|
|
18
|
+
revoked_reason text,
|
|
19
|
+
unique (resource_type, resource_uid, pointer)
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
create index if not exists ix_sbf_handle_resource on sbf_handle (resource_type, resource_uid);
|
|
23
|
+
|
|
24
|
+
create table if not exists sbf_handle_snapshot (
|
|
25
|
+
snapshot_id bigserial primary key,
|
|
26
|
+
handle_uid uuid not null references sbf_handle (handle_uid),
|
|
27
|
+
envelope_dir text not null check (envelope_dir in ('request', 'response', 'error')),
|
|
28
|
+
operation_id text not null,
|
|
29
|
+
contract_hash text not null,
|
|
30
|
+
payload jsonb not null,
|
|
31
|
+
recorded_at timestamptz not null default now()
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
create index if not exists ix_sbf_handle_snapshot_handle_uid on sbf_handle_snapshot (handle_uid, recorded_at desc);
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { plan } from './java-spring/plan.mjs';
|
|
2
|
+
import { emitJavaSpring } from './java-spring/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). This is the ORIGINAL handles codegen
|
|
7
|
+
// (pre-G4: handles/plan.mjs + handles/emit.mjs, un-abstracted) extracted behind the same
|
|
8
|
+
// interface handles/providers/python-fastapi.mjs implements -- see D-handles-providers for why
|
|
9
|
+
// this extraction only happened once a real second provider existed to factor a boundary
|
|
10
|
+
// against.
|
|
11
|
+
export const provider = {
|
|
12
|
+
contract: 'sbf.handles-provider/1',
|
|
13
|
+
id: 'java-spring',
|
|
14
|
+
title: 'Java / Spring Boot',
|
|
15
|
+
requiresCapabilities: ['resource.fetch'],
|
|
16
|
+
outputs: { spec: ['handles/migration.sql'] },
|
|
17
|
+
plan,
|
|
18
|
+
emit({ repoRoot, featureId, plan: handlesPlan, resourceFilter = null, force = false, reason = '', dryRun = false, computeDiff = false }) {
|
|
19
|
+
return emitJavaSpring({ repoRoot, featureId, plan: handlesPlan, basePackage: handlesPlan.basePackage, resourceFilter, force, reason, dryRun, computeDiff });
|
|
20
|
+
},
|
|
21
|
+
};
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { emitUnits, unifiedDiff } from '../../_engine.mjs';
|
|
5
|
+
import { sha256File } from '../../../lib/fsutil.mjs';
|
|
6
|
+
import { specPath } from '../../../lib/paths.mjs';
|
|
7
|
+
import { loadFeatureFile } from '../../../lib/featurelifecycle.mjs';
|
|
8
|
+
|
|
9
|
+
const PROVIDER_ROOT = path.dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const TEMPLATES_DIR = path.join(PROVIDER_ROOT, 'templates');
|
|
11
|
+
const RESOLVER_TEMPLATE = path.join(TEMPLATES_DIR, 'resolver.py.tmpl');
|
|
12
|
+
const MIGRATION_TEMPLATE = path.join(TEMPLATES_DIR, 'migration.sql.tmpl');
|
|
13
|
+
|
|
14
|
+
function writeUnit(target, content) {
|
|
15
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
16
|
+
fs.writeFileSync(target, content);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function render(templatePath, vars) {
|
|
20
|
+
let content = fs.readFileSync(templatePath, 'utf8');
|
|
21
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
22
|
+
content = content.replaceAll(`{{${key}}}`, String(value));
|
|
23
|
+
}
|
|
24
|
+
return content;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// PascalCase -> snake_case, good enough for the class names this scanner actually extracts
|
|
28
|
+
// (ASCII identifiers only, same assumption java-spring's own naming makes).
|
|
29
|
+
function snakeCase(s) {
|
|
30
|
+
return s.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function dottedModulePath(file, importRoot) {
|
|
34
|
+
const rel = path.relative(importRoot, file).replace(/\.py$/, '');
|
|
35
|
+
return rel.split(path.sep).join('.');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// See DECISIONS.md D-handles-providers. G4 follow-up: migration.sql + a real recover() lifecycle
|
|
39
|
+
// (tables.py/handle_service.py/record_snapshot.py) are now generated, mirroring java-spring's own
|
|
40
|
+
// O4 work -- the EXCLUDED section's original "even Java hasn't got O4" reasoning is stale, see
|
|
41
|
+
// that entry's own follow-up paragraph. `router.py` is only emitted when a SessionDep-shaped alias
|
|
42
|
+
// was actually found (plan()'s willGenerateResolver gate already implies this for every resolver,
|
|
43
|
+
// but the router itself is infra -- generated once, independent of which resolvers exist -- so it
|
|
44
|
+
// needs its own guard for the "found zero resolvers, and specifically because no SessionDep
|
|
45
|
+
// exists" case).
|
|
46
|
+
export function emitPythonFastApi({ repoRoot, featureId, plan, resourceFilter = null, force = false, reason = '', dryRun = false, computeDiff = false }) {
|
|
47
|
+
const handlesDir = path.join(plan.importRoot, plan.topPackage, 'handles');
|
|
48
|
+
const resolversDir = path.join(handlesDir, 'resolvers');
|
|
49
|
+
|
|
50
|
+
const infraUnits = [
|
|
51
|
+
{ id: '__init__.py.tmpl', templatePath: path.join(TEMPLATES_DIR, '__init__.py.tmpl'), targetAbs: path.join(handlesDir, '__init__.py'), rendered: render(path.join(TEMPLATES_DIR, '__init__.py.tmpl'), {}) },
|
|
52
|
+
{ id: 'codec.py.tmpl', templatePath: path.join(TEMPLATES_DIR, 'codec.py.tmpl'), targetAbs: path.join(handlesDir, 'codec.py'), rendered: render(path.join(TEMPLATES_DIR, 'codec.py.tmpl'), {}) },
|
|
53
|
+
{ id: 'registry.py.tmpl', templatePath: path.join(TEMPLATES_DIR, 'registry.py.tmpl'), targetAbs: path.join(handlesDir, 'registry.py'), rendered: render(path.join(TEMPLATES_DIR, 'registry.py.tmpl'), {}) },
|
|
54
|
+
{ id: 'resolvers_init.py.tmpl', templatePath: path.join(TEMPLATES_DIR, 'resolvers_init.py.tmpl'), targetAbs: path.join(resolversDir, '__init__.py'), rendered: render(path.join(TEMPLATES_DIR, 'resolvers_init.py.tmpl'), {}) },
|
|
55
|
+
// G4 follow-up (D-handles-providers): tables.py has zero {{VAR}} substitutions (same class
|
|
56
|
+
// as codec.py.tmpl above -- fixed schema, not per-feature), handle_service.py/
|
|
57
|
+
// record_snapshot.py each need only {{PKG}} to resolve their own sibling-module imports.
|
|
58
|
+
{ id: 'tables.py.tmpl', templatePath: path.join(TEMPLATES_DIR, 'tables.py.tmpl'), targetAbs: path.join(handlesDir, 'tables.py'), rendered: render(path.join(TEMPLATES_DIR, 'tables.py.tmpl'), {}) },
|
|
59
|
+
{ id: 'handle_service.py.tmpl', templatePath: path.join(TEMPLATES_DIR, 'handle_service.py.tmpl'), targetAbs: path.join(handlesDir, 'handle_service.py'), rendered: render(path.join(TEMPLATES_DIR, 'handle_service.py.tmpl'), { PKG: plan.topPackage }) },
|
|
60
|
+
{ id: 'record_snapshot.py.tmpl', templatePath: path.join(TEMPLATES_DIR, 'record_snapshot.py.tmpl'), targetAbs: path.join(handlesDir, 'record_snapshot.py'), rendered: render(path.join(TEMPLATES_DIR, 'record_snapshot.py.tmpl'), { PKG: plan.topPackage }) },
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
const sessionDep = plan.resources.find((r) => r.sessionDep)?.sessionDep ?? null;
|
|
64
|
+
if (sessionDep) {
|
|
65
|
+
infraUnits.push({
|
|
66
|
+
id: 'router.py.tmpl',
|
|
67
|
+
templatePath: path.join(TEMPLATES_DIR, 'router.py.tmpl'),
|
|
68
|
+
targetAbs: path.join(handlesDir, 'router.py'),
|
|
69
|
+
rendered: render(path.join(TEMPLATES_DIR, 'router.py.tmpl'), {
|
|
70
|
+
PKG: plan.topPackage,
|
|
71
|
+
SESSION_DEP_MODULE: dottedModulePath(sessionDep.file, plan.importRoot),
|
|
72
|
+
SESSION_DEP_NAME: sessionDep.name,
|
|
73
|
+
}),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// G4 follow-up (D-handles-providers): mirrors java-spring/emit.mjs's own contractRefFor/
|
|
78
|
+
// featureUidFor exactly, including the cross-feature adoption-safety fix that item's own Java
|
|
79
|
+
// work found the hard way -- proactively included here rather than rediscovered in Python
|
|
80
|
+
// later. requireNamedGate(root, 'contract', ...) already ran before emitPythonFastApi() is
|
|
81
|
+
// ever reached (cmdHandlesEmit's own precondition), so this feature's own contract file is
|
|
82
|
+
// guaranteed to exist here.
|
|
83
|
+
const contractRefFor = (id) => sha256File(specPath(repoRoot, id, 'contracts', `${id}.schema.json`));
|
|
84
|
+
const featureUidFor = (id) => loadFeatureFile(repoRoot, id)?.feature_uid ?? '00000000-0000-0000-0000-000000000000';
|
|
85
|
+
const contractRef = contractRefFor(featureId);
|
|
86
|
+
const featureUid = featureUidFor(featureId);
|
|
87
|
+
|
|
88
|
+
const resolverUnits = plan.resources
|
|
89
|
+
.filter((r) => r.willGenerateResolver)
|
|
90
|
+
.map((resource) => {
|
|
91
|
+
const vars = {
|
|
92
|
+
FEATURE_ID: featureId,
|
|
93
|
+
RESOURCE_TYPE: resource.type,
|
|
94
|
+
MODEL: resource.type,
|
|
95
|
+
PUBLIC_MODEL: resource.publicModel,
|
|
96
|
+
MODEL_IMPORT: resource.modelImport,
|
|
97
|
+
PKG: plan.topPackage,
|
|
98
|
+
FETCH_ROUTE_FILE: resource.fetchRoute ? path.relative(repoRoot, resource.fetchRoute.file) : '(unknown)',
|
|
99
|
+
FETCH_ROUTE_LINE: resource.fetchRoute ? resource.fetchRoute.line : '',
|
|
100
|
+
CONTRACT_REF: contractRef,
|
|
101
|
+
FEATURE_UID: featureUid,
|
|
102
|
+
};
|
|
103
|
+
return {
|
|
104
|
+
id: 'resolver.py.tmpl',
|
|
105
|
+
resourceType: resource.type,
|
|
106
|
+
module: plan.module,
|
|
107
|
+
templatePath: RESOLVER_TEMPLATE,
|
|
108
|
+
targetAbs: path.join(resolversDir, `${snakeCase(resource.type)}.py`),
|
|
109
|
+
rendered: render(RESOLVER_TEMPLATE, vars),
|
|
110
|
+
// FEATURE_ID/CONTRACT_REF/FEATURE_UID all change between features -- deliberately NOT
|
|
111
|
+
// reused verbatim for a DIFFERENT owner (see java-spring/emit.mjs's own identical
|
|
112
|
+
// comment): O2's cross-feature adoption check re-renders using the ORIGINAL owner's
|
|
113
|
+
// feature_id specifically, so baking in the CURRENT run's own contract_ref/feature_uid
|
|
114
|
+
// there would compare disk content against the wrong feature's values and manufacture a
|
|
115
|
+
// false conflict for an untouched file.
|
|
116
|
+
pristineRenderFor: (ownerId) => render(RESOLVER_TEMPLATE, {
|
|
117
|
+
...vars,
|
|
118
|
+
FEATURE_ID: ownerId,
|
|
119
|
+
CONTRACT_REF: ownerId === featureId ? contractRef : contractRefFor(ownerId),
|
|
120
|
+
FEATURE_UID: ownerId === featureId ? featureUid : featureUidFor(ownerId),
|
|
121
|
+
}),
|
|
122
|
+
};
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const orphanScan = (!resourceFilter && plan.module) ? {
|
|
126
|
+
dir: resolversDir,
|
|
127
|
+
module: plan.module,
|
|
128
|
+
matchesFile: (file) => file.endsWith('.py') && file !== '__init__.py',
|
|
129
|
+
// Filename can't reliably recover the type (organization_policy.py could be OrganizationPolicy
|
|
130
|
+
// or Organizationpolicy) -- read the `type = "X"` class attribute the resolver template
|
|
131
|
+
// itself carries instead of guessing from the filename, unlike java-spring's orphan scan.
|
|
132
|
+
resourceTypeOf: (_file, content) => {
|
|
133
|
+
const m = content.match(/^\s*type\s*=\s*"([^"]+)"/m);
|
|
134
|
+
return m ? m[1] : null;
|
|
135
|
+
},
|
|
136
|
+
} : null;
|
|
137
|
+
|
|
138
|
+
const result = emitUnits({ repoRoot, featureId, provider: 'python-fastapi', force, reason, infraUnits, resolverUnits, orphanScan, dryRun, computeDiff });
|
|
139
|
+
|
|
140
|
+
// G4 follow-up (D-handles-providers): mirrors java-spring/emit.mjs's own migration.sql
|
|
141
|
+
// handling exactly -- regenerated fresh every run, unconditionally, never manifest-tracked
|
|
142
|
+
// (no conflict detection for it at all). `kind: 'spec'` tags it distinctly from the
|
|
143
|
+
// manifest-tracked infra/resolver kinds, same D4/outputs.spec category P4's conformance
|
|
144
|
+
// harness already special-cases.
|
|
145
|
+
const migrationContent = render(MIGRATION_TEMPLATE, { FEATURE_ID: featureId });
|
|
146
|
+
const migrationPath = path.join(repoRoot, 'specs', featureId, 'handles', 'migration.sql');
|
|
147
|
+
const migrationRelPath = path.relative(repoRoot, migrationPath);
|
|
148
|
+
const migrationDiskContent = fs.existsSync(migrationPath) ? fs.readFileSync(migrationPath, 'utf8') : null;
|
|
149
|
+
const migrationAction = migrationDiskContent === null ? 'create' : (migrationDiskContent === migrationContent ? 'unchanged' : 'update');
|
|
150
|
+
if (!dryRun) writeUnit(migrationPath, migrationContent);
|
|
151
|
+
result.written.push(migrationRelPath);
|
|
152
|
+
const migrationActionEntry = { path: migrationRelPath, kind: 'spec', action: migrationAction };
|
|
153
|
+
if (computeDiff && migrationAction === 'update') migrationActionEntry.diff = unifiedDiff(migrationRelPath, migrationDiskContent, migrationContent);
|
|
154
|
+
result.actions.push(migrationActionEntry);
|
|
155
|
+
|
|
156
|
+
const postEmitNotes = [
|
|
157
|
+
'NOT done automatically: applying specs/<id>/handles/migration.sql to any database. Review it and apply yourself.',
|
|
158
|
+
];
|
|
159
|
+
if (!sessionDep) {
|
|
160
|
+
postEmitNotes.push('router.py was NOT generated -- no SessionDep-shaped dependency alias was found under the detected package, see plan notes.');
|
|
161
|
+
} else {
|
|
162
|
+
postEmitNotes.push(`NOT done automatically: wiring the generated router into your app -- add "from ${plan.topPackage}.handles.router import router as handles_router" and include it via your app's own router-composition file (e.g. api_router.include_router(handles_router)) by hand.`);
|
|
163
|
+
}
|
|
164
|
+
// G4 follow-up (D-handles-providers): record_snapshot.py's decorator needs nothing extra
|
|
165
|
+
// installed (unlike Java's spring-boot-starter-aop requirement) -- Python decorators need no
|
|
166
|
+
// framework support -- but still requires a human to apply it to their own code, same "review
|
|
167
|
+
// and apply yourself" boundary as the migration note above.
|
|
168
|
+
postEmitNotes.push('NOT done automatically: applying @record_snapshot (handles/record_snapshot.py) to any of your own service functions. Codegen never touches existing business logic files.');
|
|
169
|
+
|
|
170
|
+
return { ...result, postEmitNotes };
|
|
171
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
// Walks up from a file's own directory while `__init__.py` exists, returning the topmost such
|
|
5
|
+
// directory (the "top package" dir), or null if the file's own directory has none at all. Not
|
|
6
|
+
// named-conditioned on any particular directory name -- a standard PyPA src-layout
|
|
7
|
+
// (`src/<package>/__init__.py`, a real `__init__.py`, just under a `src/` dir) walks up correctly
|
|
8
|
+
// like any other layout (see D-typescript-express-provider's slice-4 correction in DECISIONS.md,
|
|
9
|
+
// and the positive regression test in test/python-fastapi-handles.test.mjs). Only PEP 420
|
|
10
|
+
// *implicit namespace packages* (omitting `__init__.py` entirely) hit the null case below --
|
|
11
|
+
// unsupported, see COST in DECISIONS.md, exit 2.
|
|
12
|
+
function packageRootFor(file) {
|
|
13
|
+
let dir = path.dirname(file);
|
|
14
|
+
if (!fs.existsSync(path.join(dir, '__init__.py'))) return null;
|
|
15
|
+
let top = dir;
|
|
16
|
+
for (;;) {
|
|
17
|
+
const parent = path.dirname(top);
|
|
18
|
+
if (parent === top || !fs.existsSync(path.join(parent, '__init__.py'))) break;
|
|
19
|
+
top = parent;
|
|
20
|
+
}
|
|
21
|
+
return top;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// O6-style ambiguity rejection (see java-spring's detectBasePackage): more than one DIFFERENT
|
|
25
|
+
// package root among this module's own files is refused with named candidates rather than
|
|
26
|
+
// silently picking one.
|
|
27
|
+
function detectImportRoot(moduleFiles, repoRoot) {
|
|
28
|
+
const roots = new Set();
|
|
29
|
+
for (const file of moduleFiles) {
|
|
30
|
+
const r = packageRootFor(file);
|
|
31
|
+
if (r) roots.add(r);
|
|
32
|
+
}
|
|
33
|
+
if (roots.size === 0) {
|
|
34
|
+
throw new Error('could not detect a Python package root (no __init__.py found above any scanned file for this module) -- this provider does not support PEP 420 implicit namespace packages (omitting __init__.py entirely) yet. A standard src-layout WITH real __init__.py files works fine.');
|
|
35
|
+
}
|
|
36
|
+
if (roots.size > 1) {
|
|
37
|
+
throw new Error(`ambiguous Python package root -- found ${roots.size} different candidates among this module's own files: ${[...roots].map((r) => path.relative(repoRoot, r)).join(', ')}. This provider doesn't support multi-package-root repos yet.`);
|
|
38
|
+
}
|
|
39
|
+
const topPackageDir = [...roots][0];
|
|
40
|
+
return { importRoot: path.dirname(topPackageDir), topPackage: path.basename(topPackageDir) };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function listPythonFilesUnder(dir) {
|
|
44
|
+
const out = [];
|
|
45
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
46
|
+
if (entry.name.startsWith('.') || entry.name === '__pycache__') continue;
|
|
47
|
+
const full = path.join(dir, entry.name);
|
|
48
|
+
if (entry.isDirectory()) out.push(...listPythonFilesUnder(full));
|
|
49
|
+
else if (entry.name.endsWith('.py')) out.push(full);
|
|
50
|
+
}
|
|
51
|
+
return out.sort();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Same "canonical fetch" concept as java-spring's findFetchOperation, keyed on `ep.method` (the
|
|
55
|
+
// Python function name) instead of `ep.operationId` -- python-fastapi's scan output always sets
|
|
56
|
+
// operationId: null (see D-fastapi-adapter), so that logic cannot be shared as-is. A GET endpoint
|
|
57
|
+
// whose path is exactly `${controller.basePath}/{param}` (one trailing path segment) on a
|
|
58
|
+
// controller whose class-name affinity-matches the entity, mirroring java-spring's own check.
|
|
59
|
+
function findFetchRoute(controllers, entityClassName) {
|
|
60
|
+
const needle = entityClassName.toLowerCase();
|
|
61
|
+
for (const controller of controllers) {
|
|
62
|
+
if (!controller.className.toLowerCase().includes(needle)) continue;
|
|
63
|
+
for (const ep of controller.endpoints) {
|
|
64
|
+
if (ep.verb !== 'GET') continue;
|
|
65
|
+
const suffix = ep.path.slice(controller.basePath.length);
|
|
66
|
+
if (/^\/\{[^/]+\}$/.test(suffix)) {
|
|
67
|
+
return { method: ep.method, path: ep.path, file: controller.file, line: ep.line, controllerClassName: controller.className };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// `class <Entity>Public(...)` -- scoped to the entity's own file. Required, not decorative: the
|
|
75
|
+
// real oracle's User table carries hashed_password with no protection besides each individual
|
|
76
|
+
// route's own `response_model=UserPublic` declaration -- a generic handle-fetch route serving
|
|
77
|
+
// multiple types can't rely on that, so a resolver is refused entirely when this class is absent
|
|
78
|
+
// rather than risk leaking a raw table row.
|
|
79
|
+
function findPublicModel(entityFile, entityClassName) {
|
|
80
|
+
if (!entityFile || !fs.existsSync(entityFile)) return null;
|
|
81
|
+
const text = fs.readFileSync(entityFile, 'utf8');
|
|
82
|
+
const re = new RegExp(`^class\\s+${entityClassName}Public\\s*\\(`, 'm');
|
|
83
|
+
return re.test(text) ? `${entityClassName}Public` : null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// `SessionDep = Annotated[Session, Depends(get_db)]` -- or whatever this app names its own
|
|
87
|
+
// session-dependency alias. Searches the whole detected package (not just this module's files,
|
|
88
|
+
// since the alias is typically declared once in a shared deps module), deterministic
|
|
89
|
+
// shallowest-then-name tie-break if more than one file declares one -- same convention
|
|
90
|
+
// scanners/adapters/python-fastapi.mjs's own byShallowestThenName helper uses.
|
|
91
|
+
const SESSION_DEP_RE = /^(\w+)\s*=\s*Annotated\[\s*Session\s*,\s*Depends\(/m;
|
|
92
|
+
|
|
93
|
+
function findSessionDep(files) {
|
|
94
|
+
const candidates = [];
|
|
95
|
+
for (const file of files) {
|
|
96
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
97
|
+
const m = text.match(SESSION_DEP_RE);
|
|
98
|
+
if (m) candidates.push({ file, name: m[1] });
|
|
99
|
+
}
|
|
100
|
+
if (candidates.length === 0) return null;
|
|
101
|
+
candidates.sort((a, b) => {
|
|
102
|
+
const depthA = a.file.split(path.sep).length;
|
|
103
|
+
const depthB = b.file.split(path.sep).length;
|
|
104
|
+
return depthA !== depthB ? depthA - depthB : a.file.localeCompare(b.file);
|
|
105
|
+
});
|
|
106
|
+
return candidates[0];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function dottedModulePath(file, importRoot) {
|
|
110
|
+
const rel = path.relative(importRoot, file).replace(/\.py$/, '');
|
|
111
|
+
return rel.split(path.sep).join('.');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// The descriptor-facing entry point (handles/providers/python-fastapi.mjs's provider.plan). See
|
|
115
|
+
// schemas/handles-plan.schema.json for the sbf.handles-plan/1 envelope this returns.
|
|
116
|
+
export function plan({ repoRoot, scanReport, module: moduleName, resourceFilter }) {
|
|
117
|
+
const targetModule = moduleName
|
|
118
|
+
? scanReport.related_modules.find((m) => m.module === moduleName)
|
|
119
|
+
: scanReport.related_modules[0];
|
|
120
|
+
|
|
121
|
+
if (!targetModule) {
|
|
122
|
+
return {
|
|
123
|
+
schema: 'sbf.handles-plan/1', provider: 'python-fastapi', module: null, resources: [],
|
|
124
|
+
notes: ['no related module in the scan report -- run `bskel scan` first, or pass --module explicitly'],
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const moduleFiles = [...targetModule.controllers.map((c) => c.file), ...targetModule.entities.map((e) => e.file)].filter(Boolean);
|
|
129
|
+
const { importRoot, topPackage } = detectImportRoot(moduleFiles, repoRoot);
|
|
130
|
+
const allProjectFiles = listPythonFilesUnder(path.join(importRoot, topPackage));
|
|
131
|
+
const sessionDep = findSessionDep(allProjectFiles);
|
|
132
|
+
|
|
133
|
+
const resources = [];
|
|
134
|
+
const notes = [];
|
|
135
|
+
|
|
136
|
+
for (const entity of targetModule.entities) {
|
|
137
|
+
if (resourceFilter && !resourceFilter.includes(entity.className)) continue;
|
|
138
|
+
const fetchRoute = findFetchRoute(targetModule.controllers, entity.className);
|
|
139
|
+
const publicModel = fetchRoute ? findPublicModel(entity.file, entity.className) : null;
|
|
140
|
+
|
|
141
|
+
if (!fetchRoute) {
|
|
142
|
+
notes.push(`${entity.className}: no single-resource GET route found on a router whose name contains "${entity.className}" -- fetch() will need to be hand-written`);
|
|
143
|
+
} else if (!publicModel) {
|
|
144
|
+
notes.push(`${entity.className}: no ${entity.className}Public class found in ${path.relative(repoRoot, entity.file)} -- resolver NOT generated (a generic handle-fetch route serializing the raw table model could leak a column the app never otherwise exposes, e.g. a password hash). Add a Public projection class and re-run.`);
|
|
145
|
+
}
|
|
146
|
+
if (fetchRoute && !entity.idField) {
|
|
147
|
+
notes.push(`${entity.className}: no primary-key field detected on the table model -- resolver NOT generated.`);
|
|
148
|
+
}
|
|
149
|
+
if (fetchRoute && publicModel && entity.idField && !sessionDep) {
|
|
150
|
+
notes.push(`${entity.className}: fetch route and Public model found, but no SessionDep-shaped dependency alias (Annotated[Session, Depends(...)]) was found anywhere under ${topPackage}/ -- resolver NOT generated.`);
|
|
151
|
+
}
|
|
152
|
+
if (fetchRoute) {
|
|
153
|
+
notes.push(`${entity.className}: static scanning cannot safely determine this route's real authorization logic (see ${path.relative(repoRoot, fetchRoute.file)}:${fetchRoute.line}) -- the generated resolver's check_access() always denies until hand-wired.`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const willGenerateResolver = Boolean(fetchRoute && publicModel && entity.idField && sessionDep);
|
|
157
|
+
|
|
158
|
+
resources.push({
|
|
159
|
+
type: entity.className,
|
|
160
|
+
table: entity.table,
|
|
161
|
+
idField: entity.idField,
|
|
162
|
+
readPath: fetchRoute ? `session.get(${entity.className}, ${entity.idField})` : null,
|
|
163
|
+
requiredAuthority: 'TODO_ACCESS_CHECK',
|
|
164
|
+
willGenerateResolver,
|
|
165
|
+
// provider-specific extras (additionalProperties: true in schemas/handles-plan.schema.json)
|
|
166
|
+
fetchRoute,
|
|
167
|
+
publicModel,
|
|
168
|
+
modelImport: dottedModulePath(entity.file, importRoot),
|
|
169
|
+
sessionDep,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (resources.length === 0) {
|
|
174
|
+
notes.push(`no entities found for module "${targetModule.module}" ${resourceFilter ? `matching --resource filter [${resourceFilter.join(', ')}]` : ''} -- nothing to plan.`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
schema: 'sbf.handles-plan/1',
|
|
179
|
+
provider: 'python-fastapi',
|
|
180
|
+
importRoot,
|
|
181
|
+
topPackage,
|
|
182
|
+
module: targetModule.module,
|
|
183
|
+
resources,
|
|
184
|
+
notes,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Generated by backend-skeleton. Do not hand-edit -- change the source template and regenerate.
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Generated by backend-skeleton. Do not hand-edit -- change the source template and regenerate,
|
|
2
|
+
or the JS/Java/Python implementations will silently diverge.
|
|
3
|
+
|
|
4
|
+
Encodes/decodes backend-skeleton "handles" (kind:type:uuid[:pointer], base64url with an "sbf1_"
|
|
5
|
+
prefix). Must stay behavior-identical to handles/codec.mjs (the JS reference implementation) --
|
|
6
|
+
verified by an executed round-trip test (test/handles-python-codec.test.mjs), not just by
|
|
7
|
+
inspection. See D-handles-providers in DECISIONS.md.
|
|
8
|
+
"""
|
|
9
|
+
import base64
|
|
10
|
+
import re
|
|
11
|
+
import uuid
|
|
12
|
+
|
|
13
|
+
NS_SBF_FIELD = uuid.UUID("a3f1c2e0-8b4d-4f1a-9c3e-1d2b3a4c5d6e")
|
|
14
|
+
|
|
15
|
+
_UUID_PATTERN = r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"
|
|
16
|
+
_HANDLE_RE = re.compile(rf"^([rfo]):([^:]+):({_UUID_PATTERN})(?::(.*))?$", re.IGNORECASE)
|
|
17
|
+
_BASE64URL_CHARSET_RE = re.compile(r"^[A-Za-z0-9_-]*$")
|
|
18
|
+
|
|
19
|
+
MAX_HANDLE_TOKEN_LENGTH = 2048
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _base64url_encode(raw: bytes) -> str:
|
|
23
|
+
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# D-security-10 parity, confirmed against this Python runtime directly: `base64.urlsafe_b64decode`
|
|
27
|
+
# silently discards characters outside the base64 alphabet instead of rejecting them -- the exact
|
|
28
|
+
# same defect handles/codec.mjs fixed for Node's `Buffer.from(str, 'base64')`. Without this
|
|
29
|
+
# explicit charset check first, a corrupted/tampered handle token could decode to a DIFFERENT
|
|
30
|
+
# valid-looking payload instead of raising.
|
|
31
|
+
def _base64url_decode(s: str) -> bytes:
|
|
32
|
+
if not _BASE64URL_CHARSET_RE.match(s):
|
|
33
|
+
raise ValueError("not valid base64url after the sbf1_ prefix")
|
|
34
|
+
# Padding restoration must match handles/codec.mjs's own formula exactly:
|
|
35
|
+
# `(4 - (str.length % 4)) % 4` -- arithmetically identical to `-len(s) % 4` in Python.
|
|
36
|
+
padded = s + "=" * (-len(s) % 4)
|
|
37
|
+
try:
|
|
38
|
+
return base64.urlsafe_b64decode(padded)
|
|
39
|
+
except Exception as exc: # binascii.Error (a ValueError subclass) on malformed input
|
|
40
|
+
raise ValueError("not valid base64url after the sbf1_ prefix") from exc
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def encode_handle(kind: str, type_: str, resource_uuid: str, pointer: str | None = None) -> str:
|
|
44
|
+
if kind not in ("r", "f", "o"):
|
|
45
|
+
raise ValueError(f'invalid handle kind "{kind}" (expected r, f, or o)')
|
|
46
|
+
if not type_ or not resource_uuid:
|
|
47
|
+
raise ValueError("encode_handle requires both type_ and resource_uuid")
|
|
48
|
+
if kind == "f" and not pointer:
|
|
49
|
+
raise ValueError("field handles (kind=f) require a JSON Pointer")
|
|
50
|
+
if kind != "f" and pointer:
|
|
51
|
+
raise ValueError(f'handle kind "{kind}" must not carry a JSON Pointer (only kind=f field handles do)')
|
|
52
|
+
raw = f"{kind}:{type_}:{resource_uuid}" + (f":{pointer}" if pointer else "")
|
|
53
|
+
return "sbf1_" + _base64url_encode(raw.encode("utf-8"))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class DecodedHandle:
|
|
57
|
+
__slots__ = ("kind", "type", "uuid", "pointer")
|
|
58
|
+
|
|
59
|
+
def __init__(self, kind: str, type_: str, resource_uuid: str, pointer: str | None):
|
|
60
|
+
self.kind = kind
|
|
61
|
+
self.type = type_
|
|
62
|
+
self.uuid = resource_uuid
|
|
63
|
+
self.pointer = pointer
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def decode_handle(token: str) -> DecodedHandle:
|
|
67
|
+
if not isinstance(token, str) or not token.startswith("sbf1_"):
|
|
68
|
+
raise ValueError('not an sbf1 handle (missing "sbf1_" prefix)')
|
|
69
|
+
if len(token) > MAX_HANDLE_TOKEN_LENGTH:
|
|
70
|
+
raise ValueError(f"handle token exceeds the maximum length of {MAX_HANDLE_TOKEN_LENGTH} characters")
|
|
71
|
+
raw = _base64url_decode(token[len("sbf1_"):]).decode("utf-8")
|
|
72
|
+
match = _HANDLE_RE.match(raw)
|
|
73
|
+
if not match:
|
|
74
|
+
raise ValueError(f'malformed handle payload after decoding: "{raw}"')
|
|
75
|
+
kind, type_, resource_uuid, pointer = match.groups()
|
|
76
|
+
return DecodedHandle(kind.lower(), type_, resource_uuid.lower(), pointer)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def derive_handle_uid(kind: str, type_: str, resource_uuid: str, pointer: str | None) -> str:
|
|
80
|
+
if kind == "r":
|
|
81
|
+
return resource_uuid
|
|
82
|
+
if kind == "f":
|
|
83
|
+
if not pointer:
|
|
84
|
+
raise ValueError("field handles require a pointer to derive handle_uid")
|
|
85
|
+
return str(uuid.uuid5(NS_SBF_FIELD, f"{type_}:{resource_uuid}:{pointer}"))
|
|
86
|
+
if kind == "o":
|
|
87
|
+
return str(uuid.uuid5(NS_SBF_FIELD, f"{type_}:{resource_uuid}:o"))
|
|
88
|
+
raise ValueError(f'invalid handle kind "{kind}"')
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# G4 follow-up (D-handles-providers): a sentinel, not `None`, for "path does not resolve" -- a
|
|
92
|
+
# literal port of handles/codec.mjs's resolveJsonPointer (which relies on JS's `undefined` being
|
|
93
|
+
# distinct from `null` at every step) would conflate "field is genuinely JSON null" with "field
|
|
94
|
+
# doesn't exist" under Python's plain `dict.get()`/`None`, turning a present-but-null field into a
|
|
95
|
+
# 404 instead of a 200 with `null` -- a real correctness regression, not a stylistic difference.
|
|
96
|
+
MISSING = object()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def resolve_json_pointer(obj, pointer: str | None):
|
|
100
|
+
if pointer is None or pointer == "":
|
|
101
|
+
return obj
|
|
102
|
+
if not pointer.startswith("/"):
|
|
103
|
+
raise ValueError(f'invalid JSON Pointer "{pointer}" -- must start with "/"')
|
|
104
|
+
parts = [p.replace("~1", "/").replace("~0", "~") for p in pointer.split("/")[1:]]
|
|
105
|
+
current = obj
|
|
106
|
+
for part in parts:
|
|
107
|
+
if current is None:
|
|
108
|
+
return MISSING
|
|
109
|
+
if isinstance(current, list):
|
|
110
|
+
if not re.fullmatch(r"-?\d+", part):
|
|
111
|
+
return MISSING
|
|
112
|
+
idx = int(part)
|
|
113
|
+
if idx < 0 or idx >= len(current):
|
|
114
|
+
return MISSING
|
|
115
|
+
current = current[idx]
|
|
116
|
+
elif isinstance(current, dict):
|
|
117
|
+
if part not in current:
|
|
118
|
+
return MISSING
|
|
119
|
+
current = current[part]
|
|
120
|
+
else:
|
|
121
|
+
return MISSING
|
|
122
|
+
return current
|
|
@@ -0,0 +1,96 @@
|
|
|
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 HandleService.java.tmpl -- the explicit API
|
|
4
|
+
that makes a real GET /handles/{handle}/recover reachable. Deliberately never auto-invoked by
|
|
5
|
+
anything generated elsewhere and never wired into any EXISTING business logic file -- call these
|
|
6
|
+
functions explicitly from your own service code at the point a resource is actually
|
|
7
|
+
created/updated, or apply @record_snapshot (see record_snapshot.py) to an existing service
|
|
8
|
+
function to have it called for you.
|
|
9
|
+
|
|
10
|
+
Plain module-level functions taking `session` explicitly, not a class with injected dependencies
|
|
11
|
+
-- matches this provider's own established convention (every resolver method already takes
|
|
12
|
+
`session` as an explicit call-time argument; there is no DI container here the way Spring's
|
|
13
|
+
@Service beans have).
|
|
14
|
+
"""
|
|
15
|
+
import uuid
|
|
16
|
+
from datetime import datetime, timezone
|
|
17
|
+
|
|
18
|
+
from sqlmodel import Session, select
|
|
19
|
+
|
|
20
|
+
from {{PKG}}.handles.codec import encode_handle, derive_handle_uid
|
|
21
|
+
from {{PKG}}.handles.tables import HandleRegistry, HandleSnapshot
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def register(
|
|
25
|
+
session: Session,
|
|
26
|
+
kind: str,
|
|
27
|
+
type_: str,
|
|
28
|
+
resource_uid: uuid.UUID,
|
|
29
|
+
pointer: str | None,
|
|
30
|
+
feature_uid: uuid.UUID,
|
|
31
|
+
operation_id: str | None,
|
|
32
|
+
contract_ref: str,
|
|
33
|
+
) -> str:
|
|
34
|
+
"""Derives handle_uid and UPSERTS the registry row -- the schema's own
|
|
35
|
+
unique(resource_type, resource_uid, pointer) constraint means the SAME (kind, type,
|
|
36
|
+
resource_uid, pointer) triple always derives the SAME handle_uid, so re-registering it is
|
|
37
|
+
expected, not an error: an existing row has its feature_uid/operation_id/contract_ref
|
|
38
|
+
refreshed, but revoked_at/revoked_reason are NEVER touched here -- re-registering a revoked
|
|
39
|
+
handle must never silently un-revoke it. Returns the encoded handle token.
|
|
40
|
+
"""
|
|
41
|
+
token = encode_handle(kind, type_, str(resource_uid), pointer)
|
|
42
|
+
handle_uid = uuid.UUID(derive_handle_uid(kind, type_, str(resource_uid), pointer))
|
|
43
|
+
existing = session.get(HandleRegistry, handle_uid)
|
|
44
|
+
if existing is None:
|
|
45
|
+
session.add(HandleRegistry(
|
|
46
|
+
handle_uid=handle_uid, kind=kind, resource_type=type_, resource_uid=resource_uid,
|
|
47
|
+
pointer=pointer, feature_uid=feature_uid, operation_id=operation_id, contract_ref=contract_ref,
|
|
48
|
+
))
|
|
49
|
+
else:
|
|
50
|
+
existing.feature_uid = feature_uid
|
|
51
|
+
existing.operation_id = operation_id
|
|
52
|
+
existing.contract_ref = contract_ref
|
|
53
|
+
session.add(existing)
|
|
54
|
+
session.commit()
|
|
55
|
+
return token
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def record_snapshot(
|
|
59
|
+
session: Session,
|
|
60
|
+
handle_uid: uuid.UUID,
|
|
61
|
+
envelope_dir: str,
|
|
62
|
+
operation_id: str,
|
|
63
|
+
contract_hash: str,
|
|
64
|
+
payload,
|
|
65
|
+
) -> None:
|
|
66
|
+
"""Records one envelope for an already-registered handle. `payload` is a plain
|
|
67
|
+
dict/list/scalar (already JSON-serializable), stored directly into the native JSONB column --
|
|
68
|
+
no manual json.dumps/json.loads round-trip exists here for `recover` to get wrong later.
|
|
69
|
+
"""
|
|
70
|
+
session.add(HandleSnapshot(
|
|
71
|
+
handle_uid=handle_uid, envelope_dir=envelope_dir, operation_id=operation_id,
|
|
72
|
+
contract_hash=contract_hash, payload=payload,
|
|
73
|
+
))
|
|
74
|
+
session.commit()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def revoke(session: Session, handle_uid: uuid.UUID, reason: str) -> None:
|
|
78
|
+
registry = session.get(HandleRegistry, handle_uid)
|
|
79
|
+
if registry is not None:
|
|
80
|
+
registry.revoked_at = datetime.now(timezone.utc)
|
|
81
|
+
registry.revoked_reason = reason
|
|
82
|
+
session.add(registry)
|
|
83
|
+
session.commit()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def prune_snapshots_older_than(session: Session, cutoff: datetime) -> int:
|
|
87
|
+
"""Retention is EXPOSED, not auto-scheduled -- nothing generated here calls this
|
|
88
|
+
automatically. Deciding how long snapshots should live, and whether a background job is even
|
|
89
|
+
appropriate for this application, is left to a human -- the same boundary this provider's own
|
|
90
|
+
migration.sql already draws around never applying itself.
|
|
91
|
+
"""
|
|
92
|
+
rows = session.exec(select(HandleSnapshot).where(HandleSnapshot.recorded_at < cutoff)).all()
|
|
93
|
+
for row in rows:
|
|
94
|
+
session.delete(row)
|
|
95
|
+
session.commit()
|
|
96
|
+
return len(rows)
|