graphddb 0.6.0 → 0.7.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.
@@ -393,7 +393,7 @@ input produces a byte-identical pre-contract document).
393
393
 
394
394
  ```jsonc
395
395
  {
396
- "version": "1.0",
396
+ "version": "1.1",
397
397
  "contracts": {
398
398
  // point query: single OR array (array → BatchGetItem)
399
399
  "ArticleById": {
@@ -180,13 +180,15 @@ BatchGet, so a `refs` read's `select` cannot push a server-side `FilterExpressio
180
180
  on the children (BatchGet has none); filter on the returned `items` if needed.
181
181
 
182
182
  > **Runtime scope.** A `refs` read fans a **parent-row list attribute** out into a
183
- > BatchGet. The static operations spec (`operations.json`) and the generated Python
184
- > runtime bind relation keys from a single scalar `{result.<field>}` and have no
185
- > parent-list key-fan-out primitive, so a `refs` relation is resolvable **only**
186
- > through the TS in-process runtime. Compiling a select that traverses a `refs`
187
- > relation to `operations.json` / generated Python **fails loudly** (rather than
188
- > emitting a physically wrong scalar-key BatchGet); resolve it in the generated-code
189
- > consumer with a separate BatchGet if you need it there.
183
+ > BatchGet. Since issue #208 (spec version 1.1) the shared operation IR carries this
184
+ > as a first-class **binding form** (`sourceList` on a `BatchGetItem` op), so a
185
+ > select that traverses a `refs` relation compiles to `operations.json` and the
186
+ > generated Python runtime resolves it with the same semantics as the TS in-process
187
+ > runtime (first-seen order, dedup, dangling refs dropped, `cursor: null`; the
188
+ > parent list attribute is projected implicitly and stripped from the result when
189
+ > unselected). One remaining spec-path difference: a client-side `filter` on a
190
+ > `refs` select is TS-in-process-only and is rejected loudly by the spec compiler
191
+ > (never silently dropped).
190
192
 
191
193
  ---
192
194
 
@@ -0,0 +1,232 @@
1
+ # GraphDDB Prepared Statements
2
+
3
+ `DDBModel.prepare($ => ({...}))` → `.execute(params)` is the read/write-unified
4
+ **prepared statement**: a declarative route body is compiled **once**, and each
5
+ `execute(params)` binds the per-call values into the precompiled plan and runs
6
+ it through the **same execution cores** `DDBModel.mutate` / `Model.query` /
7
+ `Model.list` and the public CQRS contracts use — effects are identical by
8
+ construction (design #203, runtime #205, compile-time transform #206, static
9
+ AOT plans #208).
10
+
11
+ ## The three execution layers (orthogonal, pick per use)
12
+
13
+ | Use | API |
14
+ |---|---|
15
+ | ad-hoc one-shot | `DDBModel.mutate({...})` / `Model.query(...)` / `Model.list(...)` — recompiles per call |
16
+ | repeated hot path (prepared statement) | `DDBModel.prepare($ => ({...}))` → `.execute(params)` |
17
+ | public CQRS contract | `publicCommandModel({...})` / `publicQueryModel({...})` |
18
+
19
+ `prepare` is the missing middle: **precompiled without the contract ceremony**.
20
+
21
+ ```ts
22
+ // write
23
+ const createPost = DDBModel.prepare(($) => ({
24
+ post: { create: () => Post, key: { threadId: $.threadId, postId: $.postId }, input: { body: $.body } },
25
+ }));
26
+ await createPost.execute({ threadId, postId, body });
27
+
28
+ // read — symmetric; only the key values (+ limit/cursor/consistentRead) are dynamic
29
+ const userById = DDBModel.prepare(($) => ({
30
+ user: { query: () => User, key: { userId: $.userId }, select: { userId: true, name: true } },
31
+ }));
32
+ await userById.execute({ userId });
33
+ ```
34
+
35
+ ## The key constraint: no runtime capture
36
+
37
+ A prepared body must be **pure-declarative**: it may reference only
38
+
39
+ - `$.<name>` — per-call values, bound at `execute`;
40
+ - **module-static** references — imports, module-scope `const` / `class` /
41
+ `enum`, the `() => Model` model refs, shared `const` select templates;
42
+ - static literals (string / number / boolean / bigint / `null`).
43
+
44
+ Capturing a per-call runtime value into the body — an enclosing function's
45
+ parameter or `let`, `p.foo`, `this`, mutable module state, any helper **call**
46
+ (the indirect-capture channel; even `Date.now()` is a fresh per-call value) —
47
+ breaks preparability and is rejected **loudly**:
48
+
49
+ - **at build time** by the static lint (`graphddb transform prepared`, below) —
50
+ the primary, phase-2 enforcement, including indirect capture via same-file
51
+ helper constants (transitively verified);
52
+ - **at runtime** by the phase-1 guard (`assertNoRuntimeCapture` and the
53
+ recording `$` proxy) as a backstop for what a per-file syntactic pass cannot
54
+ see (cross-module mutable state, mutated template objects).
55
+
56
+ ## Zero overhead vs. fallback: the compile-time transform
57
+
58
+ **With the transform applied, prepared statements are zero-overhead per
59
+ operation regardless of where you write them. Without it, they still behave
60
+ identically — they just pay a small per-call memoization cost.** That is the
61
+ whole contract:
62
+
63
+ | Build | Per-op cost of an inline `prepare(fn).execute()` | Result |
64
+ |---|---|---|
65
+ | **AOT** (`graphddb transform prepared --aot <artifact> --write`, #208) | **zero compilation at runtime, including the first call** — the plan was compiled at BUILD time into a static artifact; the call site loads it and every call binds params into the frozen plan | identical |
66
+ | **transform applied** (`graphddb transform prepared --write`) | zero per-op after the FIRST call (the lazy slot compiles once per module at first use) | identical |
67
+ | **no transform** (fallback) | phase-1 **structural memoization**: the body is re-evaluated and structure-hashed per call, then the compiled handle is reused from a bounded LRU (no recompile) | identical |
68
+ | hand-hoisted module-level `const stmt = DDBModel.prepare(...)` | compile once at first use (runtime); the transform leaves it untouched | identical |
69
+
70
+ The transform exists to remove the placement footgun: without it, an inline
71
+ `prepare` is *correct but warmer* than a module-level one (silent-slow). With
72
+ it, both spellings compile to the same optimal form.
73
+
74
+ ### The three optimization tiers — when to use which
75
+
76
+ 1. **AOT static plans** (`--aot`, #208) — production builds with a build step.
77
+ Plan construction happens at BUILD time: cold starts (Lambda SnapStart,
78
+ first request) pay **zero** compilation, and a broken plan fails the
79
+ **build**, not the first request. Requires committing the generated
80
+ artifact module and running the drift check in CI (below).
81
+ 2. **Lazy slot + structural memoization** (`--write`, #206 / #205) — dev runs,
82
+ `tsx`/`ts-node` no-build-step deployments, and every call site the AOT pass
83
+ did not rewrite (e.g. `prepare` calls inside `node_modules`). The plan
84
+ compiles at runtime, once.
85
+ 3. **Ad-hoc `mutate` / `query`** — one-shot operations where preparing buys
86
+ nothing (recompiles per call, by design).
87
+
88
+ The runtime structural-memoization cache is deliberately **kept** even when AOT
89
+ is applied: untransformed builds and third-party `prepare` call sites fall back
90
+ to it with identical behavior (perf only differs), so a build without the
91
+ transform can never become silently per-call-slow.
92
+
93
+ ## True build-time compilation: `--aot` static plans (#208)
94
+
95
+ ```bash
96
+ # compile every verified prepare body at BUILD time, write the static plan
97
+ # artifact module, and rewrite call sites to load it
98
+ graphddb transform prepared --src src/ --aot src/graphddb.prepared.ts --write
99
+
100
+ # CI drift gate: recompile and compare — exits 1 when model declarations (or
101
+ # prepare bodies) changed without regenerating the artifact
102
+ graphddb transform prepared --src src/ --aot src/graphddb.prepared.ts
103
+ ```
104
+
105
+ The rewrite keeps the body **in source** — it is the plan's compile source, the
106
+ type source, and the untransformed-build fallback; at runtime the loader never
107
+ evaluates it:
108
+
109
+ ```ts
110
+ // after `--aot src/graphddb.prepared.ts --write`
111
+ import { loadPreparedPlan as __gddbLoadPreparedPlan } from 'graphddb';
112
+ import __gddbPreparedPlans from './graphddb.prepared.js';
113
+ let __gddbPrepared1;
114
+ export async function getUser(userId: string) {
115
+ return (__gddbPrepared1 ??= __gddbLoadPreparedPlan(__gddbPreparedPlans, 'src/app.ts#1', ($) => ({
116
+ u: { query: () => User, key: { userId: $.userId }, select: USER_SELECT },
117
+ }))).execute({ userId });
118
+ }
119
+ ```
120
+
121
+ What the artifact carries (the same serialized-IR vocabulary `operations.json`
122
+ uses — templated key conditions / transaction items, model references by
123
+ **entity name**):
124
+
125
+ - a **write** plan freezes the whole mutation compile — lifecycle resolution
126
+ and every derived effect (edges, counters, uniqueness guards, outbox,
127
+ idempotency, maintenance writes, referential ConditionChecks) — into one
128
+ declarative `TransactionSpec` per op;
129
+ - a **read** plan carries the analyzed route (entity, select template, param
130
+ binds, options) plus its physical `QuerySpec`, so a broken read plan fails
131
+ the build.
132
+
133
+ **Stale plans never execute.** Each plan records a fingerprint of every entity
134
+ declaration it touches; the loader recomputes them against the live
135
+ `MetadataRegistry` on first use and rejects loudly on any mismatch (model /
136
+ table-mapping drift since generation), version skew, or a missing plan id — in
137
+ addition to the CI drift gate above.
138
+
139
+ ### CLI
140
+
141
+ ```bash
142
+ # build lint (CI gate): verify no-runtime-capture, report hoistable inline
143
+ # call sites as notes; exit 1 on violations
144
+ graphddb transform prepared --src src/
145
+
146
+ # apply: rewrite verified inline call sites in place (idempotent)
147
+ graphddb transform prepared --src src/ --write
148
+ ```
149
+
150
+ The rewrite normalizes each inline call site to a module-scope **lazy slot**:
151
+
152
+ ```ts
153
+ // before (inline, per-call structural memoization)
154
+ export async function getUser(userId: string) {
155
+ return DDBModel.prepare(($) => ({
156
+ u: { query: () => User, key: { userId: $.userId }, select: USER_SELECT },
157
+ })).execute({ userId });
158
+ }
159
+
160
+ // after `graphddb transform prepared --write`
161
+ let __gddbPrepared1;
162
+ export async function getUser(userId: string) {
163
+ return (__gddbPrepared1 ??= DDBModel.prepare(($) => ({
164
+ u: { query: () => User, key: { userId: $.userId }, select: USER_SELECT },
165
+ }))).execute({ userId });
166
+ }
167
+ ```
168
+
169
+ The body stays lexically in place (every reference resolves exactly as
170
+ before — no TDZ / module-init-order hazards with models declared later in the
171
+ file), the plan compiles once per module at first use, and every later call is
172
+ a single nullish check straight into `.execute`. Files containing any
173
+ violation are **never rewritten** — the build fails loudly instead.
174
+
175
+ A file is scanned when it contains a `DDBModel.prepare` call site; detection
176
+ follows import aliases (`import { DDBModel as M }`), namespace imports
177
+ (`g.DDBModel`), module-const aliases, and same-file `extends DDBModel`
178
+ subclasses. Call sites already evaluated once per module load (module-scope
179
+ `const`, static class fields) are left as-is.
180
+
181
+ ### Programmatic API
182
+
183
+ ```ts
184
+ import { transformPreparedSource, lintPreparedSource } from 'graphddb/transform';
185
+
186
+ const result = transformPreparedSource(fileName, sourceText); // { text, changed, diagnostics, ... }
187
+ const diagnostics = lintPreparedSource(fileName, sourceText); // check-only (the build gate)
188
+ ```
189
+
190
+ Build-time only (requires the optional `typescript` dependency, like the JSDoc
191
+ codegen pass); wire it into a bundler plugin or a pre-build step if you prefer
192
+ that over the CLI.
193
+
194
+ ### What the static lint proves — and its limits
195
+
196
+ The lint accepts only the declarative grammar the runtime itself accepts
197
+ (object/array literals, static literals, `$.<name>`, module-static reference
198
+ chains, `() => Model` thunks), applied **transitively** through same-file
199
+ module-const templates. It therefore rejects, per-file and soundly: direct
200
+ captures (enclosing-scope bindings, `this`), indirect captures via **any** call
201
+ or `new`, mutable module state (`let` / `var`), unresolved globals, per-call
202
+ control flow (conditionals, computed keys, substitution templates), and bodies
203
+ it cannot statically see.
204
+
205
+ Per-file syntactic analysis cannot prove two things: an **imported** binding is
206
+ accepted as module-static (that is the design's allowance for `() => Model`
207
+ refs — a pathological mutable re-export is invisible), and mutation of a
208
+ same-file `const` **object** between calls. Both are backstopped by the
209
+ phase-1 runtime guard (only `$`-refs and scalar static literals are bindable
210
+ value leaves) and by the fallback's correctness: an undetected unhoistable
211
+ site is never wrong, only warmer.
212
+
213
+ Detection also does **not check the import's module specifier**: a binding
214
+ imported as `DDBModel` from **any** package (`import { DDBModel } from
215
+ '<other-package>'`) matches, so another library's same-named `DDBModel.prepare`
216
+ call site is transformed / linted too (a false transform or a spurious lint
217
+ error is possible). Take care in files that mix graphddb with a same-named
218
+ foreign API.
219
+
220
+ ## Semantics (both layers, guaranteed identical)
221
+
222
+ - **write** — the params-independent op template compiles once
223
+ (`compileWriteFragment`); `execute` runs the identical core the envelope and
224
+ `publicCommandModel` use: derived maintainers and atomic
225
+ `TransactWriteItems` semantics match exactly. Multi-alias bodies compose one
226
+ atomic transaction (`mode: 'parallel'` opts out per call).
227
+ - **read** — the params-independent products (resolved model, normalized
228
+ select, validated relation structure) compute once; `execute` binds key
229
+ values + per-call pagination/consistency and runs `executeQuery` /
230
+ `executeList` exactly like `Model.query` / `Model.list`.
231
+ - A body is all-read or all-write; an option the target core would ignore is a
232
+ loud compile-time reject (never a silent drop).
@@ -153,7 +153,7 @@ so the output is stable across runs.
153
153
 
154
154
  ```jsonc
155
155
  {
156
- "version": "1.0",
156
+ "version": "1.1",
157
157
  "tables": { "UserPermissions": { "physicalName": "UserPermissions" } },
158
158
  "entities": {
159
159
  "UserModel": {
@@ -199,14 +199,17 @@ so the output is stable across runs.
199
199
  - `key` / `gsis[*]` carry `inputFields` and `pkTemplate` / `skTemplate` where the
200
200
  templates use `{field}` placeholders (e.g. `USER#{userId}`, `EMAIL#{email}`).
201
201
  `skTemplate` is `null` when the access pattern has no sort key.
202
- - `relations[*]` is one of `hasMany | hasOne | belongsTo` with a `target` entity
203
- and a `keyBinding` (target field → source field).
202
+ - `relations[*]` is one of `hasMany | hasOne | belongsTo | refs` with a `target`
203
+ entity and a `keyBinding` (target field → source field). A `refs` relation
204
+ (issue #197 / #208) additionally carries `refs: { from, key }` — the parent
205
+ LIST attribute holding the inline reference elements and the field read off
206
+ each element.
204
207
 
205
208
  ### 4.2 `operations.json` — Execution Specs
206
209
 
207
210
  ```ts
208
211
  interface OperationsDocument {
209
- readonly version: '1.0';
212
+ readonly version: '1.1';
210
213
  readonly queries: Record<string, QuerySpec>;
211
214
  readonly commands: Record<string, CommandSpec>;
212
215
  readonly transactions?: Record<string, TransactionSpec>;
@@ -468,6 +471,23 @@ GraphDDBRuntime(
468
471
  `table_mapping` maps logical table names to deployed physical names. The
469
472
  specs are loaded and cached at construction.
470
473
 
474
+ **Spec-version guard (#208).** The IR is a stable execution contract, so
475
+ construction validates both documents' `version` against
476
+ `SPEC_VERSION_SUPPORTED` (currently `1.1`) and raises `GraphDDBError` — loudly,
477
+ never a silent per-operation mis-execution — when a document cannot be
478
+ faithfully executed. The compatibility rule for a `<major>.<minor>` document:
479
+
480
+ - the **major** must equal the supported major (a major bump changes the shape
481
+ of existing vocabulary);
482
+ - the **minor** must be **≤** the supported minor — minors are additive (e.g.
483
+ `1.1` added the `sourceList` list fan-out binding form for `refs`), so every
484
+ *older* document is fully understood, while a *newer* one may carry binding
485
+ forms this runtime does not implement and is forward-rejected;
486
+ - a missing or malformed `version` is rejected.
487
+
488
+ Upgrade `graphddb_runtime` (or regenerate the documents with a matching
489
+ generator) to clear the error.
490
+
471
491
  | Method | Behavior |
472
492
  |---|---|
473
493
  | `execute_query(query_id, params, options=None) -> dict \| None` | Run a single- or multi-operation query; returns the hydrated result, or `None` when the root matches nothing. `options` may carry a pagination `cursor`. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "graphddb",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
4
4
  "description": "Graph data modeling on DynamoDB with adjacency list pattern",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -29,6 +29,10 @@
29
29
  "./cdc": {
30
30
  "types": "./dist/cdc/index.d.ts",
31
31
  "import": "./dist/cdc/index.js"
32
+ },
33
+ "./transform": {
34
+ "types": "./dist/transform/index.d.ts",
35
+ "import": "./dist/transform/index.js"
32
36
  }
33
37
  },
34
38
  "bin": {