graphddb 0.5.3 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -39,14 +39,14 @@ graph, the stream drain, and the rebuild planner consume every pattern uniformly
39
39
  | # | Pattern | graphddb feature | Status |
40
40
  |---|---|---|---|
41
41
  | 1 | [Same Partition / Containment](#1-same-partition--containment) | segmented SK + `hasMany`, `pattern: 'samePartition'` | ✅ Phase 1 |
42
- | 2 | [Embedded Refs](#2-embedded-refs) | `embeddedSnapshot` collection projecting id fields only | ✅ Phase 1 (sync append) / stream for trim |
42
+ | 2 | [Embedded Refs](#2-embedded-refs) | `@list` id-list + `@refs` relation (read = one deduped BatchGet) | ✅ Phase 1 (sync append) / stream for trim |
43
43
  | 3 | [Embedded Snapshot](#3-embedded-snapshot) | `pattern: 'embeddedSnapshot'` (snapshot / collection) | ✅ Phase 1 |
44
44
  | 4 | [Edge / Adjacency](#4-edge--adjacency) | `edgeWrites` `putEdge` / `deleteEdge` / `updateKeyChangeEdge` | ✅ Phase 1 |
45
45
  | 5 | [Materialized View](#5-materialized-view) | `@model({ kind: 'materializedView' })` + `@maintainedFrom` | ✅ Phase 2 (stream) |
46
46
  | 6 | [Sparse View](#6-sparse-view) | `@model({ kind: 'sparseView' })` + `@maintainedFrom` + `whenMember` | ✅ Phase 3 (stream-only) |
47
47
  | 7 | [Aggregate Counter](#7-aggregate-counter) | `@aggregate(..., { pattern: 'counter', value: count() })` | ✅ Phase 1 (`count()`) / stream (`max()`) |
48
48
  | 8 | [Versioned / Latest Pointer](#8-versioned--latest-pointer) | `@hasOne(... versionedLatest)` + `@hasMany(... versionedHistory)` | ✅ Phase 3 |
49
- | 9 | [Reverse Lookup](#9-reverse-lookup) | GSI (both directions) or `dualEdge` two-row sync | ✅ Phase 1 (GSI) / Phase 3 (`dualEdge`) |
49
+ | 9 | [Reverse Lookup](#9-reverse-lookup) | GSI (both directions) or `dualEdge` two-row sync | ✅ Phase 1 (GSI) / `dualEdge` (#133, command-IF driven #195) |
50
50
  | 10 | [External Projection](#10-external-projection) | typed-consumer-IF contract (`@cdcProjected` + `fromChange` / `subscribe`) | ✅ |
51
51
 
52
52
  ---
@@ -117,31 +117,76 @@ without a query; the child bodies are fetched on demand (e.g. a follow-up
117
117
  `BatchGet`). On the table this is an attribute holding a list of id strings/maps on
118
118
  the parent item.
119
119
 
120
- **(b) How to declare it.** There is no dedicated `refs` preset; embedded refs are an
121
- **`embeddedSnapshot` collection that projects only the id field(s)** the snapshot
122
- machinery, narrowed to keys. (The RFC marks this pattern ⚠️ precisely because it is
123
- not its own primitive but a thin projection of the snapshot collection.)
120
+ **(b) How to declare it write (maintain the id-list) + read (resolve the bodies).**
121
+ Embedded refs have two sides, and each has a first-class declaration.
122
+
123
+ The **stored id-list** is just a `@list` attribute on the parent; you append to it
124
+ however you maintain the parent (directly, or as a narrowed `embeddedSnapshot`
125
+ collection that projects only the id field). The **read side** is a dedicated
126
+ **`refs` relation** (`@refs`) that resolves that inline id-list into the referenced
127
+ child bodies in ONE deduped `BatchGet`:
124
128
 
125
129
  ```ts
126
- @hasMany(() => PostModel, { threadId: 'threadId' }, {
127
- pattern: 'embeddedSnapshot',
128
- read: { maxItems: 20, order: 'DESC' },
129
- write: { maintainedOn: ['Post.created'], updateMode: 'stream' },
130
- projection: { postId: 'postId' }, // ids only — the "refs" shape
131
- })
132
- postRefs!: PostModel[];
130
+ @model({ table: TABLE, prefix: 'Post' })
131
+ class PostModel extends DDBModel {
132
+ static readonly keys = key<{ threadId: string; postId: string }>((c) => ({
133
+ pk: k`THREAD#${c.threadId}`,
134
+ sk: k`POST#${c.postId}`,
135
+ }));
136
+ @string threadId!: string;
137
+ @string postId!: string;
138
+ // The stored inline id-list (ids only — the "refs" shape).
139
+ @list tagRefs!: { tagId: string }[];
140
+
141
+ // READ side: resolve `tagRefs[].tagId` → the `Tag` bodies, one deduped BatchGet.
142
+ @refs(() => TagModel, { from: 'tagRefs', key: 'tagId' })
143
+ tags!: TagModel[];
144
+ }
133
145
  ```
134
146
 
135
- **(c) Read & write behavior.** The ref list is read **inline** off the parent row
136
- (it *is* the maintained access path). On `Post.created` the maintainer appends the
137
- projected `{ postId }` ref; resolving the bodies is a separate `BatchGet` against
138
- the children's keys.
147
+ `from` names the parent list attribute; `key` is the ref field read off each element
148
+ (add `to` when the target's key field name differs from `key`). The decorated
149
+ property is declared as `TagModel[]`, so it selects and returns like a bounded
150
+ collection.
151
+
152
+ **(c) Read & write behavior.** The id-list is maintained inline on the parent row
153
+ (it *is* the materialized access path). Resolving the child bodies is a single
154
+ declarative query — the `refs` relation reads the parent, pulls each `tagRefs`
155
+ element's `tagId`, **dedupes**, and hydrates the `Tag` bodies in ONE `BatchGetItem`
156
+ (never a per-ref `GetItem`) — the exact physical cost (parent read + 1 BatchGet) of a
157
+ hand-written two-call `Get` + `BatchGet`, but declarative and typed:
158
+
159
+ ```ts
160
+ // ONE query — parent read + one deduped BatchGet for the referenced tag bodies.
161
+ const post = await Post.query(
162
+ { threadId, postId },
163
+ { postId: true, tagRefs: true, tags: { select: { tagId: true, name: true } } },
164
+ );
165
+ // post.tags → { items: { tagId: string; name: string }[]; cursor: null }
166
+ ```
139
167
 
140
- **(d) Constraints & phase status.** The synchronous append is Phase 1; bounding
141
- the list (`maxItems` trim) or removing a ref on `Post.removed` requires the
142
- read-modify-write **stream** path (declare `updateMode: 'stream'`), since a
143
- synchronous collection is append-only. Same payload-only projection rule as
144
- [Embedded Snapshot](#3-embedded-snapshot).
168
+ `select` projects the child body fields (the result is fully typed to the
169
+ projection); a repeated ref costs nothing extra (deduped into one BatchGet key); a
170
+ ref whose body is absent is skipped. The connection carries a `null` cursor a refs
171
+ list is finite, not paged. This is the same nested-BatchGet auto-resolution
172
+ `@belongsTo`/`@hasOne` use, extended to fan out over a **parent-row list** rather
173
+ than a scalar key.
174
+
175
+ **(d) Constraints & phase status.** ✅ The read-side `refs` relation is first-class
176
+ in the **TS in-process runtime** (`Model.query`). Maintaining the id-list
177
+ synchronously (append) is Phase 1; bounding it (`maxItems` trim) or removing a ref
178
+ requires the read-modify-write **stream** path. The child bodies are resolved via
179
+ BatchGet, so a `refs` read's `select` cannot push a server-side `FilterExpression`
180
+ on the children (BatchGet has none); filter on the returned `items` if needed.
181
+
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.
145
190
 
146
191
  ---
147
192
 
@@ -185,6 +230,53 @@ single mirrored row in place; a `hasMany` collection appends a projected item
185
230
  (`list_append`). On the stream path, the collection additionally trims to `maxItems`
186
231
  and splices on `removed`.
187
232
 
233
+ **Reading the snapshot (type-safe, no join).** The maintained copy lives on the owner
234
+ row, so it is read with a single point read — but it must NOT be confused with a
235
+ **traversal** to the relation's real child rows. GraphDDB keeps the two apart in both
236
+ the types and the runtime:
237
+
238
+ - **Collection (`@hasMany` embeddedSnapshot).** Select the field with
239
+ `{ inline: true }` to read the *maintained copy stored on the owner row* — projected
240
+ off that one row, returned in the field's declared shape, no child Query:
241
+
242
+ ```ts
243
+ // INLINE READ — the maintained latest-N copy on the ThreadSummary owner row.
244
+ const summary = await ThreadSummary.query(
245
+ { threadId },
246
+ { latestPosts: { inline: true } },
247
+ );
248
+ summary?.latestPosts; // → the projected [{ postId, textPreview }, …] off the row
249
+
250
+ // TRAVERSAL — a DIFFERENT read: a Query against the real child Post partition,
251
+ // returning a `{ items, cursor }` connection of the actual child rows.
252
+ const traversed = await ThreadSummary.query(
253
+ { threadId },
254
+ { latestPosts: { select: { postId: true, body: true } } },
255
+ );
256
+ traversed?.latestPosts.items; // → the real child Posts (full bodies), not the copy
257
+ ```
258
+
259
+ `{ inline: true }` (the maintained copy) and `{ select: … }` (a child traversal) are
260
+ structurally distinct shapes, so the compiler tells them apart; at runtime an
261
+ `{ inline: true }` on any field that is **not** an `embeddedSnapshot` relation is a
262
+ loud error, so the inline read can never be silently mistaken for a traversal (or a
263
+ bogus attribute). The owner row's key fields are encoded in the PK/SK (known from the
264
+ query key) and are not re-stored as top-level scalars, so an inline read selects the
265
+ maintained attribute itself.
266
+
267
+ - **Single-row mirror (`@belongsTo`/`@hasOne` embeddedSnapshot).** The mirror projects
268
+ its captured fields onto the owner row's **own scalar columns** (the `projection` map
269
+ keys — e.g. `displayName`, `bioPreview` on the `ProfileCard`). Those are already
270
+ first-class typed fields, so the mirror is read inline with an ordinary scalar select
271
+ — no cast, no traversal:
272
+
273
+ ```ts
274
+ const card = await ProfileCard.query(
275
+ { userId },
276
+ { displayName: true, bioPreview: true }, // the mirrored snapshot, read inline
277
+ );
278
+ ```
279
+
188
280
  **(d) Constraints & phase status.** ✅ Phase 1 for the synchronous path. A
189
281
  synchronous collection is **append-only** — `maxItems` trim and `removed`-driven
190
282
  splice are the **stream** path ([§7.2](./spec.md#72-stream-maintenance--outbox--cdc-drain-updatemode-stream-130)).
@@ -444,8 +536,18 @@ physical rows** (a forward edge and an inverse edge on its own partition).
444
536
 
445
537
  **(b) How to declare it.** *GSI form* — declare a `gsi` on the edge that swaps the key
446
538
  so the reverse `@hasMany` resolves against it (no extra write; DynamoDB maintains the
447
- GSI). *Dual-edge form* — `edgeWrites` with `w.dualEdge(forward, inverse)` to keep a
448
- **two-row** bidirectional edge in sync without an inverse GSI.
539
+ GSI). *Dual-edge form* — declare `w.dualEdge(forward, inverse)` on the forward edge to
540
+ keep a **two-row** bidirectional edge in sync without an inverse GSI. The dual edge can
541
+ be recorded in either of two write vocabularies carrying the SAME `w.dualEdge`:
542
+
543
+ - **`entityWrites` (recommended when driving through the command / mutation IF, #195)** —
544
+ place `w.dualEdge(...)` in a lifecycle's `edges` array. The dual edge is then derived by
545
+ `executeCommandMethod` / `DDBModel.mutate`, so a `mutation` over the edge maintains BOTH
546
+ rows in the SAME atomic transaction with no low-level handwork. Use this form if the edge
547
+ is created / removed through a command.
548
+ - **`edgeWrites` (the low-level edge-only primitive)** — the historical adjacency-only
549
+ `writes` member. Same two-row synchronization, but driven through the low-level edge-write
550
+ path rather than the command IF.
449
551
 
450
552
  ```ts
451
553
  // GSI form: one row, reverse direction served by a GSI.
@@ -461,9 +563,28 @@ class GroupMembershipModel extends DDBModel {
461
563
  @string userId!: string;
462
564
  }
463
565
 
464
- // Dual-edge form: two physical rows kept in sync (no inverse GSI).
566
+ // Dual-edge form (command-IF driven, recommended): two physical rows kept in sync
567
+ // (no inverse GSI). `w.dualEdge` sits in a lifecycle's `edges`, so a `mutation` over
568
+ // the edge derives both rows in one atomic transaction (#195).
465
569
  @model({ table: 'App', prefix: 'FOLLOW' })
466
570
  class FollowEdgeModel extends DDBModel {
571
+ // … key: FOLLOWER#{a} / FOLLOWING#{b}
572
+ static readonly writes = entityWrites<FollowEdgeModel>((w) => ({
573
+ create: w.lifecycle({
574
+ edges: [
575
+ w.dualEdge(
576
+ { target: () => UserModel, relationProperty: 'following' },
577
+ { adjacency: () => FollowedByEdgeModel, target: () => UserModel, relationProperty: 'followers' },
578
+ ),
579
+ ],
580
+ }),
581
+ }));
582
+ }
583
+
584
+ // Dual-edge form (low-level `edgeWrites` primitive): same two-row synchronization,
585
+ // driven through the edge-write path rather than the command IF.
586
+ @model({ table: 'App', prefix: 'FOLLOW' })
587
+ class FollowEdgeModelLowLevel extends DDBModel {
467
588
  // … key: FOLLOWER#{a} / FOLLOWING#{b}
468
589
  static readonly writes = edgeWrites((w) => [
469
590
  w.dualEdge(
@@ -482,8 +603,10 @@ together, so the two directions stay consistent without a GSI.
482
603
 
483
604
  **(d) Constraints & phase status.** ✅ Phase 1 for the GSI form (the existing
484
605
  bidirectional edge: base row + inverse GSI). The two-row `dualEdge` synchronization
485
- is Phase 3 (#133). A dual edge is *exactly* two rows a round-trip guard validates
486
- the inverse half (and rejects pointing both directions at the same adjacency model).
606
+ landed in #133 and is reachable through the command / mutation IF via the recommended
607
+ `entityWrites` form in #195. A dual edge is *exactly* two rows a round-trip guard
608
+ validates the inverse half (and rejects pointing both directions at the same adjacency
609
+ model).
487
610
 
488
611
  ---
489
612
 
@@ -0,0 +1,165 @@
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).
9
+
10
+ ## The three execution layers (orthogonal, pick per use)
11
+
12
+ | Use | API |
13
+ |---|---|
14
+ | ad-hoc one-shot | `DDBModel.mutate({...})` / `Model.query(...)` / `Model.list(...)` — recompiles per call |
15
+ | repeated hot path (prepared statement) | `DDBModel.prepare($ => ({...}))` → `.execute(params)` |
16
+ | public CQRS contract | `publicCommandModel({...})` / `publicQueryModel({...})` |
17
+
18
+ `prepare` is the missing middle: **precompiled without the contract ceremony**.
19
+
20
+ ```ts
21
+ // write
22
+ const createPost = DDBModel.prepare(($) => ({
23
+ post: { create: () => Post, key: { threadId: $.threadId, postId: $.postId }, input: { body: $.body } },
24
+ }));
25
+ await createPost.execute({ threadId, postId, body });
26
+
27
+ // read — symmetric; only the key values (+ limit/cursor/consistentRead) are dynamic
28
+ const userById = DDBModel.prepare(($) => ({
29
+ user: { query: () => User, key: { userId: $.userId }, select: { userId: true, name: true } },
30
+ }));
31
+ await userById.execute({ userId });
32
+ ```
33
+
34
+ ## The key constraint: no runtime capture
35
+
36
+ A prepared body must be **pure-declarative**: it may reference only
37
+
38
+ - `$.<name>` — per-call values, bound at `execute`;
39
+ - **module-static** references — imports, module-scope `const` / `class` /
40
+ `enum`, the `() => Model` model refs, shared `const` select templates;
41
+ - static literals (string / number / boolean / bigint / `null`).
42
+
43
+ Capturing a per-call runtime value into the body — an enclosing function's
44
+ parameter or `let`, `p.foo`, `this`, mutable module state, any helper **call**
45
+ (the indirect-capture channel; even `Date.now()` is a fresh per-call value) —
46
+ breaks preparability and is rejected **loudly**:
47
+
48
+ - **at build time** by the static lint (`graphddb transform prepared`, below) —
49
+ the primary, phase-2 enforcement, including indirect capture via same-file
50
+ helper constants (transitively verified);
51
+ - **at runtime** by the phase-1 guard (`assertNoRuntimeCapture` and the
52
+ recording `$` proxy) as a backstop for what a per-file syntactic pass cannot
53
+ see (cross-module mutable state, mutated template objects).
54
+
55
+ ## Zero overhead vs. fallback: the compile-time transform
56
+
57
+ **With the transform applied, prepared statements are zero-overhead per
58
+ operation regardless of where you write them. Without it, they still behave
59
+ identically — they just pay a small per-call memoization cost.** That is the
60
+ whole contract:
61
+
62
+ | Build | Per-op cost of an inline `prepare(fn).execute()` | Result |
63
+ |---|---|---|
64
+ | **transform applied** (`graphddb transform prepared --write`) | **zero** planning / compiling / structure hashing — the call site is normalized to a module-scope prepared slot; after the first call it is a nullish check + `.execute` | identical |
65
+ | **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 |
66
+ | hand-hoisted module-level `const stmt = DDBModel.prepare(...)` | zero (already optimal; the transform leaves it untouched) | identical |
67
+
68
+ The transform exists to remove the placement footgun: without it, an inline
69
+ `prepare` is *correct but warmer* than a module-level one (silent-slow). With
70
+ it, both spellings compile to the same optimal form.
71
+
72
+ ### CLI
73
+
74
+ ```bash
75
+ # build lint (CI gate): verify no-runtime-capture, report hoistable inline
76
+ # call sites as notes; exit 1 on violations
77
+ graphddb transform prepared --src src/
78
+
79
+ # apply: rewrite verified inline call sites in place (idempotent)
80
+ graphddb transform prepared --src src/ --write
81
+ ```
82
+
83
+ The rewrite normalizes each inline call site to a module-scope **lazy slot**:
84
+
85
+ ```ts
86
+ // before (inline, per-call structural memoization)
87
+ export async function getUser(userId: string) {
88
+ return DDBModel.prepare(($) => ({
89
+ u: { query: () => User, key: { userId: $.userId }, select: USER_SELECT },
90
+ })).execute({ userId });
91
+ }
92
+
93
+ // after `graphddb transform prepared --write`
94
+ let __gddbPrepared1;
95
+ export async function getUser(userId: string) {
96
+ return (__gddbPrepared1 ??= DDBModel.prepare(($) => ({
97
+ u: { query: () => User, key: { userId: $.userId }, select: USER_SELECT },
98
+ }))).execute({ userId });
99
+ }
100
+ ```
101
+
102
+ The body stays lexically in place (every reference resolves exactly as
103
+ before — no TDZ / module-init-order hazards with models declared later in the
104
+ file), the plan compiles once per module at first use, and every later call is
105
+ a single nullish check straight into `.execute`. Files containing any
106
+ violation are **never rewritten** — the build fails loudly instead.
107
+
108
+ A file is scanned when it contains a `DDBModel.prepare` call site; detection
109
+ follows import aliases (`import { DDBModel as M }`), namespace imports
110
+ (`g.DDBModel`), module-const aliases, and same-file `extends DDBModel`
111
+ subclasses. Call sites already evaluated once per module load (module-scope
112
+ `const`, static class fields) are left as-is.
113
+
114
+ ### Programmatic API
115
+
116
+ ```ts
117
+ import { transformPreparedSource, lintPreparedSource } from 'graphddb/transform';
118
+
119
+ const result = transformPreparedSource(fileName, sourceText); // { text, changed, diagnostics, ... }
120
+ const diagnostics = lintPreparedSource(fileName, sourceText); // check-only (the build gate)
121
+ ```
122
+
123
+ Build-time only (requires the optional `typescript` dependency, like the JSDoc
124
+ codegen pass); wire it into a bundler plugin or a pre-build step if you prefer
125
+ that over the CLI.
126
+
127
+ ### What the static lint proves — and its limits
128
+
129
+ The lint accepts only the declarative grammar the runtime itself accepts
130
+ (object/array literals, static literals, `$.<name>`, module-static reference
131
+ chains, `() => Model` thunks), applied **transitively** through same-file
132
+ module-const templates. It therefore rejects, per-file and soundly: direct
133
+ captures (enclosing-scope bindings, `this`), indirect captures via **any** call
134
+ or `new`, mutable module state (`let` / `var`), unresolved globals, per-call
135
+ control flow (conditionals, computed keys, substitution templates), and bodies
136
+ it cannot statically see.
137
+
138
+ Per-file syntactic analysis cannot prove two things: an **imported** binding is
139
+ accepted as module-static (that is the design's allowance for `() => Model`
140
+ refs — a pathological mutable re-export is invisible), and mutation of a
141
+ same-file `const` **object** between calls. Both are backstopped by the
142
+ phase-1 runtime guard (only `$`-refs and scalar static literals are bindable
143
+ value leaves) and by the fallback's correctness: an undetected unhoistable
144
+ site is never wrong, only warmer.
145
+
146
+ Detection also does **not check the import's module specifier**: a binding
147
+ imported as `DDBModel` from **any** package (`import { DDBModel } from
148
+ '<other-package>'`) matches, so another library's same-named `DDBModel.prepare`
149
+ call site is transformed / linted too (a false transform or a spurious lint
150
+ error is possible). Take care in files that mix graphddb with a same-named
151
+ foreign API.
152
+
153
+ ## Semantics (both layers, guaranteed identical)
154
+
155
+ - **write** — the params-independent op template compiles once
156
+ (`compileWriteFragment`); `execute` runs the identical core the envelope and
157
+ `publicCommandModel` use: derived maintainers and atomic
158
+ `TransactWriteItems` semantics match exactly. Multi-alias bodies compose one
159
+ atomic transaction (`mode: 'parallel'` opts out per call).
160
+ - **read** — the params-independent products (resolved model, normalized
161
+ select, validated relation structure) compute once; `execute` binds key
162
+ values + per-call pagination/consistency and runs `executeQuery` /
163
+ `executeList` exactly like `Model.query` / `Model.list`.
164
+ - A body is all-read or all-write; an option the target core would ignore is a
165
+ loud compile-time reject (never a silent drop).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "graphddb",
3
- "version": "0.5.3",
3
+ "version": "0.7.0",
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": {
@@ -56,7 +60,13 @@
56
60
  "size": "size-limit",
57
61
  "size:why": "size-limit --why",
58
62
  "docker:up": "docker compose -f docker-compose.test.yml up -d",
59
- "docker:down": "docker compose -f docker-compose.test.yml down"
63
+ "docker:down": "docker compose -f docker-compose.test.yml down",
64
+ "bench:docker:up": "docker compose -p graphddb-bench -f docker-compose.bench.yml up -d",
65
+ "bench:docker:down": "docker compose -p graphddb-bench -f docker-compose.bench.yml down",
66
+ "embedoc:build": "embedoc build",
67
+ "embedoc:watch": "embedoc watch",
68
+ "embedoc:generate": "embedoc generate --all",
69
+ "embedoc:check": "node scripts/embedoc-drift-check.mjs"
60
70
  },
61
71
  "size-limit": [
62
72
  {
@@ -108,6 +118,8 @@
108
118
  "dynamodb-onetable": "^2.7.7",
109
119
  "dynamodb-toolbox": "^2.9.0",
110
120
  "electrodb": "^3.9.1",
121
+ "embedoc": "0.14.2",
122
+ "prettier": "^3.9.4",
111
123
  "size-limit": "^11.0.0",
112
124
  "tsup": "^8.0.0",
113
125
  "tsx": "^4.22.4",