graphddb 0.5.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -0
- package/dist/cdc/index.d.ts +37 -0
- package/dist/cdc/index.js +29 -0
- package/dist/{chunk-QBXLQNXY.js → chunk-5NXNEW43.js} +4 -1
- package/dist/{chunk-W3GEJPPV.js → chunk-F2DI3GTI.js} +154 -1024
- package/dist/chunk-MMVHOUM4.js +24 -0
- package/dist/{chunk-H5TUW2WR.js → chunk-N5NQM3SO.js} +3749 -3606
- package/dist/chunk-PDUVTYC5.js +992 -0
- package/dist/cli.js +922 -19
- package/dist/from-change-CFzBy7aU.d.ts +327 -0
- package/dist/index-CtJbTrfB.d.ts +690 -0
- package/dist/index.d.ts +53 -1113
- package/dist/index.js +48 -41
- package/dist/linter/index.d.ts +126 -0
- package/dist/linter/index.js +36 -0
- package/dist/{types-m1Ect6hG.d.ts → maintenance-view-adapter-CFeasCKo.d.ts} +380 -187
- package/dist/registry-DbqmFyab.d.ts +76 -0
- package/dist/relation-depth-0TiWr5OW.d.ts +36 -0
- package/dist/spec/index.d.ts +4 -0
- package/dist/spec/index.js +54 -0
- package/dist/testing/index.d.ts +1 -1
- package/dist/testing/index.js +5 -4
- package/docs/design-patterns.md +150 -27
- package/docs/doc-sample.md +263 -0
- package/docs/docs-generation.md +183 -0
- package/package.json +55 -4
- package/dist/chunk-MCKGQKYU.js +0 -15
- package/dist/typescript-ZUQEBJRV.js +0 -210764
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { m as EntityMetadata } from './maintenance-view-adapter-CFeasCKo.js';
|
|
2
|
+
|
|
3
|
+
interface LintRule {
|
|
4
|
+
id: string;
|
|
5
|
+
severity: 'error' | 'warning';
|
|
6
|
+
check(metadata: EntityMetadata, registry: typeof MetadataRegistry): LintResult[];
|
|
7
|
+
}
|
|
8
|
+
interface LintResult {
|
|
9
|
+
ruleId: string;
|
|
10
|
+
severity: 'error' | 'warning';
|
|
11
|
+
message: string;
|
|
12
|
+
entity: string;
|
|
13
|
+
field?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
declare class Linter {
|
|
17
|
+
private rules;
|
|
18
|
+
addRule(rule: LintRule): void;
|
|
19
|
+
run(metadata: EntityMetadata, registry: typeof MetadataRegistry): LintResult[];
|
|
20
|
+
runAll(registry: typeof MetadataRegistry): LintResult[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
declare class MetadataRegistry {
|
|
24
|
+
private static store;
|
|
25
|
+
private static _linter;
|
|
26
|
+
/**
|
|
27
|
+
* A monotonically-increasing counter bumped on every change to the set of
|
|
28
|
+
* registered models ({@link register} / {@link clear}). It lets a consumer that
|
|
29
|
+
* caches a structure derived from the whole registry (e.g. the maintenance graph
|
|
30
|
+
* in `src/spec/mutation-command.ts`) detect that the registry has changed since
|
|
31
|
+
* the cache was built and rebuild — closing the silent-drop window where a model
|
|
32
|
+
* registered after a cache build would be invisible to it. Only the membership
|
|
33
|
+
* of {@link store} changes here; per-model lazy finalize ({@link finalize}) does
|
|
34
|
+
* not bump it (it adds no relations/triggers, so it cannot change the graph).
|
|
35
|
+
*/
|
|
36
|
+
private static _generation;
|
|
37
|
+
/**
|
|
38
|
+
* The current registry generation (see {@link _generation}). A consumer caches
|
|
39
|
+
* the value it built against and rebuilds when it no longer matches.
|
|
40
|
+
*/
|
|
41
|
+
static get generation(): number;
|
|
42
|
+
static register(target: Function, metadata: EntityMetadata): void;
|
|
43
|
+
static get(target: Function): EntityMetadata;
|
|
44
|
+
static has(target: Function): boolean;
|
|
45
|
+
static getAll(): Map<Function, EntityMetadata>;
|
|
46
|
+
/**
|
|
47
|
+
* Snapshot of the entities that are **already finalized**, WITHOUT triggering
|
|
48
|
+
* finalize on any pending entity (unlike {@link getAll}). This is the safe view
|
|
49
|
+
* for a cross-entity lint rule that runs *inside* `finalize`: eagerly
|
|
50
|
+
* finalizing the rest of the registry from within a finalize pass would
|
|
51
|
+
* re-enter (and duplicate) the in-progress entity's finalize. A whole-registry
|
|
52
|
+
* rule instead reasons over the already-finalized peers plus the entity
|
|
53
|
+
* currently being checked; any conflicting pair is caught when the later of the
|
|
54
|
+
* two finalizes (both are present by then).
|
|
55
|
+
*
|
|
56
|
+
* @internal — for cross-entity linter rules.
|
|
57
|
+
*/
|
|
58
|
+
static getFinalized(): Map<Function, EntityMetadata>;
|
|
59
|
+
static get linter(): Linter | null;
|
|
60
|
+
static set linter(linter: Linter | null);
|
|
61
|
+
/** Resets linter to the environment default (built-in rule set outside production). */
|
|
62
|
+
static resetLinter(): void;
|
|
63
|
+
/**
|
|
64
|
+
* @internal — for testing only. Resets to a no-op linter (no rules): matches
|
|
65
|
+
* the pre-#189 `new Linter()` behaviour where a cleared registry does not lint
|
|
66
|
+
* on re-registration. `null` is finalize-equivalent to an empty linter (the
|
|
67
|
+
* `finalize` guard skips a null linter), and nothing reads the linter getter
|
|
68
|
+
* to invoke it, so this preserves existing test semantics without importing
|
|
69
|
+
* the `Linter` class as a value (keeping it out of a production bundle).
|
|
70
|
+
*/
|
|
71
|
+
static clear(): void;
|
|
72
|
+
private static finalize;
|
|
73
|
+
private static handleLintResults;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export { type LintRule as L, MetadataRegistry as M, type LintResult as a, Linter as b };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { b as Linter, L as LintRule } from './registry-DbqmFyab.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Creates a Linter pre-loaded with rules safe for Entity registration.
|
|
5
|
+
*
|
|
6
|
+
* Rules included: no-scan, require-limit, gsi-ambiguity, missing-gsi,
|
|
7
|
+
* relation-depth, same-partition-preset, plus the Epic #118 Phase 1 maintenance
|
|
8
|
+
* validators (issue #126): missing-context, 400kb, hot-partition, fan-out,
|
|
9
|
+
* multi-maintainer-same-row. The maintenance validators are no-ops for any
|
|
10
|
+
* relation that does not declare `write.maintainedOn`, so unannotated models are
|
|
11
|
+
* unaffected. query-boundary is excluded because it flags non-unique GSIs which
|
|
12
|
+
* are valid for list() operations; boundary enforcement is handled at runtime by
|
|
13
|
+
* the planner's boundary-check.
|
|
14
|
+
*
|
|
15
|
+
* Also includes cfn-schema-consistency (issue #169): a whole-table validator
|
|
16
|
+
* that rejects entities sharing a physical table with an ambiguous base
|
|
17
|
+
* KeySchema, inconsistently-shared GSI index names, or more than 20 unioned
|
|
18
|
+
* GSIs — the CFn deploy-consistency user-error class, caught statically at
|
|
19
|
+
* finalize. It is additive: a single entity on its own table with ≤ 20 GSIs is
|
|
20
|
+
* never affected.
|
|
21
|
+
*/
|
|
22
|
+
declare function createDefaultLinter(): Linter;
|
|
23
|
+
|
|
24
|
+
declare const noScanRule: LintRule;
|
|
25
|
+
|
|
26
|
+
declare const requireLimitRule: LintRule;
|
|
27
|
+
|
|
28
|
+
declare const queryBoundaryRule: LintRule;
|
|
29
|
+
|
|
30
|
+
declare const gsiAmbiguityRule: LintRule;
|
|
31
|
+
|
|
32
|
+
declare const missingGsiRule: LintRule;
|
|
33
|
+
|
|
34
|
+
declare const relationDepthRule: LintRule;
|
|
35
|
+
|
|
36
|
+
export { requireLimitRule as a, createDefaultLinter as c, gsiAmbiguityRule as g, missingGsiRule as m, noScanRule as n, queryBoundaryRule as q, relationDepthRule as r };
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { $ as BridgeBundle, a1 as CommandContractMethodSpec, a2 as CommandResolutionTarget, L as CommandSpec, a3 as CompiledFragment, a4 as CompiledMutationPlan, a5 as ComposeSpec, a6 as CompositionPlanSpec, a0 as ConditionSpec, O as ContextSpec, a7 as ContractCardinality, a8 as ContractCommandResult, a9 as ContractInputArity, aa as ContractKeySpec, ab as ContractKind, ac as ContractResolution, J as ContractSpec, ad as DerivedConditionCheck, ae as DerivedEdgeWrite, af as DerivedIdempotencyGuard, ag as DerivedMaintainOutbox, ah as DerivedMaintainWrite, ai as DerivedOutboxEvent, aj as DerivedUniqueGuard, ak as DerivedUpdate, al as EntityRefResolver, am as ExecutionPlanSpec, an as FilterSpec, ao as MAX_TRANSACT_COMPOSE_ITEMS, _ as Manifest, ap as ManifestEntity, aq as ManifestField, ar as ManifestFieldType, as as ManifestGsi, at as ManifestKey, au as ManifestRelation, av as ManifestTable, aw as OperationSpec, Y as OperationsDocument, ax as ParamSpec, ay as QueryContractMethodSpec, K as QuerySpec, az as RangeConditionSpec, aA as ReadOperationType, aB as SPEC_VERSION, aC as TransactionItemSpec, aD as TransactionItemType, N as TransactionSpec, aE as WhenSpec, aF as WriteOperationType, aG as assertNoCrossFragmentMaintainCollision, aH as compileFragment, aI as compileMutationPlan, aJ as compileSingleFragmentPlan, aK as resetMaintenanceGraphCache, aL as resolveLifecycle, aM as resolveMaintainers } from '../maintenance-view-adapter-CFeasCKo.js';
|
|
2
|
+
export { A as AnyModelContract, B as BuiltContracts, C as ContextOwnership, a as ContextOwnershipMap, b as ContractBoundaryViolation, c as ContractInputs, d as ContractMap, e as ContractN1Violation, o as assertBundleSerializable, p as assertContractBoundaries, q as assertContractN1Safe, r as assertJsonSerializable, s as assertSupportedCondition, t as buildBridgeBundle, u as buildContexts, v as buildContracts, w as buildManifest, x as buildOperations, y as buildQuerySpec, z as buildTransactionSpec, D as buildTransactions, E as collectContractBoundaryViolations, F as collectContractN1Violations } from '../index-CtJbTrfB.js';
|
|
3
|
+
import '../registry-DbqmFyab.js';
|
|
4
|
+
import '@aws-sdk/client-dynamodb';
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import {
|
|
2
|
+
MAX_TRANSACT_COMPOSE_ITEMS,
|
|
3
|
+
SPEC_VERSION,
|
|
4
|
+
assertBundleSerializable,
|
|
5
|
+
assertContractBoundaries,
|
|
6
|
+
assertContractN1Safe,
|
|
7
|
+
assertJsonSerializable,
|
|
8
|
+
assertNoCrossFragmentMaintainCollision,
|
|
9
|
+
assertSupportedCondition,
|
|
10
|
+
buildBridgeBundle,
|
|
11
|
+
buildContexts,
|
|
12
|
+
buildContracts,
|
|
13
|
+
buildManifest,
|
|
14
|
+
buildOperations,
|
|
15
|
+
buildQuerySpec,
|
|
16
|
+
buildTransactionSpec,
|
|
17
|
+
buildTransactions,
|
|
18
|
+
collectContractBoundaryViolations,
|
|
19
|
+
collectContractN1Violations,
|
|
20
|
+
compileFragment,
|
|
21
|
+
compileMutationPlan,
|
|
22
|
+
compileSingleFragmentPlan,
|
|
23
|
+
resetMaintenanceGraphCache,
|
|
24
|
+
resolveLifecycle,
|
|
25
|
+
resolveMaintainers
|
|
26
|
+
} from "../chunk-N5NQM3SO.js";
|
|
27
|
+
import "../chunk-F2DI3GTI.js";
|
|
28
|
+
import "../chunk-PDUVTYC5.js";
|
|
29
|
+
export {
|
|
30
|
+
MAX_TRANSACT_COMPOSE_ITEMS,
|
|
31
|
+
SPEC_VERSION,
|
|
32
|
+
assertBundleSerializable,
|
|
33
|
+
assertContractBoundaries,
|
|
34
|
+
assertContractN1Safe,
|
|
35
|
+
assertJsonSerializable,
|
|
36
|
+
assertNoCrossFragmentMaintainCollision,
|
|
37
|
+
assertSupportedCondition,
|
|
38
|
+
buildBridgeBundle,
|
|
39
|
+
buildContexts,
|
|
40
|
+
buildContracts,
|
|
41
|
+
buildManifest,
|
|
42
|
+
buildOperations,
|
|
43
|
+
buildQuerySpec,
|
|
44
|
+
buildTransactionSpec,
|
|
45
|
+
buildTransactions,
|
|
46
|
+
collectContractBoundaryViolations,
|
|
47
|
+
collectContractN1Violations,
|
|
48
|
+
compileFragment,
|
|
49
|
+
compileMutationPlan,
|
|
50
|
+
compileSingleFragmentPlan,
|
|
51
|
+
resetMaintenanceGraphCache,
|
|
52
|
+
resolveLifecycle,
|
|
53
|
+
resolveMaintainers
|
|
54
|
+
};
|
package/dist/testing/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { n as Executor, D as DynamoDBOperation, o as ReadExecOptions, p as ExecutorResult, q as BatchGetExecInput, P as PutInput, W as WriteExecOptions, r as WriteResult, s as UpdateInput, t as DeleteInput, u as BatchWriteExecItem, v as BatchExecOptions, T as TransactWriteExecItem, M as ModelStatic, w as DDBModel, c as ChangeEvent } from '../maintenance-view-adapter-CFeasCKo.js';
|
|
2
2
|
import '@aws-sdk/client-dynamodb';
|
|
3
3
|
|
|
4
4
|
/**
|
package/dist/testing/index.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import {
|
|
2
2
|
createCdcEmulator
|
|
3
|
-
} from "../chunk-
|
|
3
|
+
} from "../chunk-5NXNEW43.js";
|
|
4
4
|
import {
|
|
5
5
|
ClientManager,
|
|
6
6
|
MetadataRegistry,
|
|
7
|
-
TableMapping,
|
|
8
7
|
resolveModelClass
|
|
9
|
-
} from "../chunk-
|
|
10
|
-
import
|
|
8
|
+
} from "../chunk-F2DI3GTI.js";
|
|
9
|
+
import {
|
|
10
|
+
TableMapping
|
|
11
|
+
} from "../chunk-PDUVTYC5.js";
|
|
11
12
|
|
|
12
13
|
// src/memory/memory-store.ts
|
|
13
14
|
function deepClone(value) {
|
package/docs/design-patterns.md
CHANGED
|
@@ -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) | `
|
|
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) /
|
|
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
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
@
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
})
|
|
132
|
-
|
|
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
|
-
|
|
136
|
-
(
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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* —
|
|
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
|
|
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
|
-
|
|
486
|
-
|
|
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
|
|