graphddb 0.4.3 → 0.5.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/README.md +12 -2
- package/dist/{chunk-CPTV3H2U.js → chunk-B3GWIWT6.js} +401 -26
- package/dist/{chunk-BROCT574.js → chunk-EMJHTUA4.js} +1 -1
- package/dist/{chunk-G5RWWBAL.js → chunk-FI63YKK5.js} +83 -153
- package/dist/cli.js +831 -9
- package/dist/index.d.ts +150 -3
- package/dist/index.js +45 -6
- package/dist/testing/index.d.ts +1 -1
- package/dist/testing/index.js +2 -2
- package/dist/{types-BWOrWcbd.d.ts → types-B9rJ1z3H.d.ts} +278 -114
- package/docs/cdc-projection.md +167 -0
- package/docs/cloudformation.md +294 -0
- package/docs/design-patterns.md +18 -13
- package/docs/spec.md +9 -4
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -19,6 +19,7 @@ TypeScript types.
|
|
|
19
19
|
- ✅ **Testing adapter** —— unit-test planner / traversal / transaction / CDC with an in-memory executor.
|
|
20
20
|
- ✅ **Middleware** —— host-side hooks for reads / writes (logging, tenant, authorization).
|
|
21
21
|
- ✅ **CDC emulator** —— drive and test change events equivalent to DynamoDB Streams locally.
|
|
22
|
+
- ✅ **CDC projection** —— `@cdcProjected()` + `fromChange` / `subscribe` parse CDC change events into typed records (sink delivery / idempotency stay with the consumer).
|
|
22
23
|
- ✅ **Maintained access paths** —— keep embedded snapshots / aggregate counters synchronized atomically.
|
|
23
24
|
|
|
24
25
|
## 🤔 Philosophy
|
|
@@ -204,6 +205,14 @@ Each capability is summarized here only. For details, operator lists, and proced
|
|
|
204
205
|
[`docs/mutation-command-derivation.md`](./docs/mutation-command-derivation.md).
|
|
205
206
|
- **CDC emulator** —— change events equivalent to DynamoDB Streams, to drive and test differential
|
|
206
207
|
aggregation. [`docs/cdc-emulator.md`](./docs/cdc-emulator.md).
|
|
208
|
+
- **CDC projection** —— a typed-consumer-IF that parses the CDC change events of a `@cdcProjected()`
|
|
209
|
+
source model into typed records. `Model.fromChange(event)` returns `[old, new]`, and
|
|
210
|
+
`DDBModel.subscribe(handlers)` (the sibling of `query` / `mutate`) produces a `ChangeHandler` the
|
|
211
|
+
consumer mounts on its own stream. graphddb owns parse→typed record only; subscription, sink
|
|
212
|
+
delivery, and idempotency are the consumer's. [`docs/cdc-projection.md`](./docs/cdc-projection.md).
|
|
213
|
+
- **CloudFormation generation** —— `graphddb generate cloudformation` emits a CloudFormation template for
|
|
214
|
+
the DynamoDB table(s) a model set maps onto (GSI union / Streams / TTL / PITR / PROVISIONED / Auto
|
|
215
|
+
Scaling). [`docs/cloudformation.md`](./docs/cloudformation.md).
|
|
207
216
|
|
|
208
217
|
## 📚 Documentation
|
|
209
218
|
|
|
@@ -222,6 +231,8 @@ documentation available offline at hand. On GitHub, follow the links in the tabl
|
|
|
222
231
|
| [Multi-language / Python bridge](./docs/python-bridge.md) | Code generation with TS as the SSoT and a Python runtime; TS↔Python conformance. |
|
|
223
232
|
| [In-memory test adapter](./docs/testing.md) | Docker-free in-process testing with `graphddb/testing` and `MemoryInspector`. |
|
|
224
233
|
| [CDC emulator](./docs/cdc-emulator.md) | A change-event emulator for differential aggregation patterns (dev/test). |
|
|
234
|
+
| [CDC projection](./docs/cdc-projection.md) | The `@cdcProjected()` + `fromChange` / `subscribe` typed-consumer-IF: a contract that parses CDC change events into typed records. Boundary (parse→typed is graphddb; subscription, sink delivery, idempotency are the consumer's). |
|
|
235
|
+
| [CloudFormation generation](./docs/cloudformation.md) | The `generate cloudformation` command: emit a CloudFormation template for the DynamoDB table(s) a model set maps onto (GSI union / Streams / TTL / PITR / TableClass / SSE / PROVISIONED / Auto Scaling). All options, derivation rules, and scope boundary. |
|
|
225
236
|
| [Benchmark](./benchmark/RESULTS.md) | Library comparison against hand-written AWS SDK, ElectroDB, DynamoDB Toolbox, and OneTable. |
|
|
226
237
|
|
|
227
238
|
## 💡 Examples
|
|
@@ -230,7 +241,7 @@ documentation available offline at hand. On GitHub, follow the links in the tabl
|
|
|
230
241
|
|---------|-------------|
|
|
231
242
|
| [user-permissions](./examples/user-permissions/) | Manages users, groups, and permissions with Single Table Design. Includes adjacency lists, inverted indexes, relation traversal, and `explain`. |
|
|
232
243
|
| [aggregate-tree-pattern](./examples/aggregate-tree-pattern/) | Differential tree aggregation (`siteScoreAverage`) driven by the CDC emulator: dirty propagation, throttled sweeps, recomputation. |
|
|
233
|
-
| [embedded-snapshot-pattern](./examples/embedded-snapshot-pattern/) | Treating a relation as a maintained access path
|
|
244
|
+
| [embedded-snapshot-pattern](./examples/embedded-snapshot-pattern/) | Treating a relation as a maintained access path: `pattern: 'embeddedSnapshot'` keeps a denormalized collection / snapshot synchronized onto the owner row in the same atomic transaction as the source write. |
|
|
234
245
|
| [aggregate-counter](./examples/aggregate-counter/) | The `@aggregate` counter from RFC §5.2: the `ThreadPost.created` / `removed` lifecycle keeps `ThreadCounter.postCount` synchronized via an atomic `ADD ±1` within the same `TransactWriteItems` as the source write. |
|
|
235
246
|
|
|
236
247
|
## 🏛 Architecture
|
|
@@ -250,7 +261,6 @@ From the same TS Model (the SSoT), multiple targets are derived:
|
|
|
250
261
|
flowchart LR
|
|
251
262
|
M["TS Model (SSoT)"] --> RT["Runtime (TS)"]
|
|
252
263
|
M --> PY["Python client + runtime"]
|
|
253
|
-
M --> OA["OpenAPI"]
|
|
254
264
|
M --> CQ["CQRS Contract"]
|
|
255
265
|
```
|
|
256
266
|
|
|
@@ -412,6 +412,21 @@ var relationDepthRule = {
|
|
|
412
412
|
}
|
|
413
413
|
};
|
|
414
414
|
|
|
415
|
+
// src/client/TableMapping.ts
|
|
416
|
+
var TableMapping = class {
|
|
417
|
+
static mapping = {};
|
|
418
|
+
static set(mapping) {
|
|
419
|
+
this.mapping = { ...mapping };
|
|
420
|
+
}
|
|
421
|
+
static resolve(declaredName) {
|
|
422
|
+
return this.mapping[declaredName] ?? declaredName;
|
|
423
|
+
}
|
|
424
|
+
/** @internal — for testing only */
|
|
425
|
+
static reset() {
|
|
426
|
+
this.mapping = {};
|
|
427
|
+
}
|
|
428
|
+
};
|
|
429
|
+
|
|
415
430
|
// src/linter/rules/same-partition-preset.ts
|
|
416
431
|
var samePartitionPresetRule = {
|
|
417
432
|
id: "same-partition-preset",
|
|
@@ -843,6 +858,112 @@ function destinationRowKey(owner, relation) {
|
|
|
843
858
|
return `${owner}#${parts.join("&")}`;
|
|
844
859
|
}
|
|
845
860
|
|
|
861
|
+
// src/linter/rules/cfn-schema-consistency.ts
|
|
862
|
+
var MAX_GSIS_PER_TABLE = 20;
|
|
863
|
+
function gsiShape(gsi2) {
|
|
864
|
+
const hasSk = gsi2.segmented.skSegments.length > 0;
|
|
865
|
+
return { indexName: gsi2.indexName, hasSk };
|
|
866
|
+
}
|
|
867
|
+
function err3(entity, message) {
|
|
868
|
+
return {
|
|
869
|
+
ruleId: "cfn-schema-consistency",
|
|
870
|
+
severity: "error",
|
|
871
|
+
message,
|
|
872
|
+
entity
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
var cfnSchemaConsistencyRule = {
|
|
876
|
+
id: "cfn-schema-consistency",
|
|
877
|
+
severity: "error",
|
|
878
|
+
check(metadata, registry) {
|
|
879
|
+
return checkAllTables(metadata, registry);
|
|
880
|
+
}
|
|
881
|
+
};
|
|
882
|
+
function checkAllTables(current, registry) {
|
|
883
|
+
const seenMeta = /* @__PURE__ */ new Set();
|
|
884
|
+
const byPhysical = /* @__PURE__ */ new Map();
|
|
885
|
+
const add = (meta) => {
|
|
886
|
+
if (seenMeta.has(meta)) return;
|
|
887
|
+
seenMeta.add(meta);
|
|
888
|
+
const physical = TableMapping.resolve(meta.tableName);
|
|
889
|
+
const list = byPhysical.get(physical) ?? [];
|
|
890
|
+
list.push(meta);
|
|
891
|
+
byPhysical.set(physical, list);
|
|
892
|
+
};
|
|
893
|
+
add(current);
|
|
894
|
+
for (const [, meta] of registry.getFinalized()) add(meta);
|
|
895
|
+
const currentPhysical = TableMapping.resolve(current.tableName);
|
|
896
|
+
const entities = byPhysical.get(currentPhysical);
|
|
897
|
+
if (!entities) return [];
|
|
898
|
+
return checkTable(currentPhysical, entities);
|
|
899
|
+
}
|
|
900
|
+
function checkTable(physical, entities) {
|
|
901
|
+
const ordered = [...entities].sort((a, b) => a.prefix.localeCompare(b.prefix));
|
|
902
|
+
const results = [];
|
|
903
|
+
results.push(...checkBaseKeySchema(physical, ordered));
|
|
904
|
+
results.push(...checkGsiUnion(physical, ordered));
|
|
905
|
+
return results;
|
|
906
|
+
}
|
|
907
|
+
function checkBaseKeySchema(physical, entities) {
|
|
908
|
+
const withSk = [];
|
|
909
|
+
const withoutSk = [];
|
|
910
|
+
for (const meta of entities) {
|
|
911
|
+
if (!meta.primaryKey) continue;
|
|
912
|
+
const hasSk = meta.primaryKey.segmented.skSegments.length > 0;
|
|
913
|
+
(hasSk ? withSk : withoutSk).push(meta.prefix);
|
|
914
|
+
}
|
|
915
|
+
if (withSk.length > 0 && withoutSk.length > 0) {
|
|
916
|
+
const offender = [...withSk, ...withoutSk].sort()[0];
|
|
917
|
+
return [
|
|
918
|
+
err3(
|
|
919
|
+
offender,
|
|
920
|
+
`CloudFormation schema consistency: physical table '${physical}' has an ambiguous base KeySchema \u2014 some entities declare a sort key and some do not (with SK: [${withSk.join(", ")}]; without SK: [${withoutSk.join(", ")}]). A single DynamoDB table has exactly one KeySchema; every entity sharing a table must agree on whether the base key has an SK.`
|
|
921
|
+
)
|
|
922
|
+
];
|
|
923
|
+
}
|
|
924
|
+
return [];
|
|
925
|
+
}
|
|
926
|
+
function checkGsiUnion(physical, entities) {
|
|
927
|
+
const results = [];
|
|
928
|
+
const seen = /* @__PURE__ */ new Map();
|
|
929
|
+
for (const meta of entities) {
|
|
930
|
+
for (const gsi2 of meta.gsiDefinitions) {
|
|
931
|
+
const shape = gsiShape(gsi2);
|
|
932
|
+
const existing = seen.get(shape.indexName);
|
|
933
|
+
if (!existing) {
|
|
934
|
+
seen.set(shape.indexName, { shape, entity: meta.prefix });
|
|
935
|
+
continue;
|
|
936
|
+
}
|
|
937
|
+
const conflict = describeConflict(existing.shape, shape);
|
|
938
|
+
if (conflict) {
|
|
939
|
+
results.push(
|
|
940
|
+
err3(
|
|
941
|
+
meta.prefix,
|
|
942
|
+
`CloudFormation schema consistency: GSI '${shape.indexName}' on physical table '${physical}' is declared inconsistently across entities that share it \u2014 ${conflict} (first declared by '${existing.entity}', conflicting declaration on '${meta.prefix}'). A single DynamoDB table has exactly one GSI per index name; entities may share an index name (projecting different logical fields into the shared physical key), but only when its physical key structure (sort-key presence) is identical.`
|
|
943
|
+
)
|
|
944
|
+
);
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
if (seen.size > MAX_GSIS_PER_TABLE) {
|
|
949
|
+
const names = [...seen.keys()].sort();
|
|
950
|
+
const offender = [...entities].map((e) => e.prefix).sort()[0];
|
|
951
|
+
results.push(
|
|
952
|
+
err3(
|
|
953
|
+
offender,
|
|
954
|
+
`CloudFormation schema consistency: physical table '${physical}' would have ${seen.size} global secondary indexes after unioning across entities, exceeding the DynamoDB limit of ${MAX_GSIS_PER_TABLE}. Distinct index names: [${names.join(", ")}]. Reduce the number of distinct GSIs on this table.`
|
|
955
|
+
)
|
|
956
|
+
);
|
|
957
|
+
}
|
|
958
|
+
return results;
|
|
959
|
+
}
|
|
960
|
+
function describeConflict(a, b) {
|
|
961
|
+
if (a.hasSk !== b.hasSk) {
|
|
962
|
+
return `one declaration has a sort key and the other does not (${a.hasSk ? "with" : "without"} SK vs. ${b.hasSk ? "with" : "without"} SK)`;
|
|
963
|
+
}
|
|
964
|
+
return null;
|
|
965
|
+
}
|
|
966
|
+
|
|
846
967
|
// src/linter/default-linter.ts
|
|
847
968
|
function createDefaultLinter() {
|
|
848
969
|
const linter = new Linter();
|
|
@@ -857,6 +978,7 @@ function createDefaultLinter() {
|
|
|
857
978
|
linter.addRule(hotPartitionRule);
|
|
858
979
|
linter.addRule(fanOutRule);
|
|
859
980
|
linter.addRule(multiMaintainerSameRowRule);
|
|
981
|
+
linter.addRule(cfnSchemaConsistencyRule);
|
|
860
982
|
return linter;
|
|
861
983
|
}
|
|
862
984
|
|
|
@@ -909,6 +1031,25 @@ var MetadataRegistry = class {
|
|
|
909
1031
|
}
|
|
910
1032
|
return new Map(this.store);
|
|
911
1033
|
}
|
|
1034
|
+
/**
|
|
1035
|
+
* Snapshot of the entities that are **already finalized**, WITHOUT triggering
|
|
1036
|
+
* finalize on any pending entity (unlike {@link getAll}). This is the safe view
|
|
1037
|
+
* for a cross-entity lint rule that runs *inside* `finalize`: eagerly
|
|
1038
|
+
* finalizing the rest of the registry from within a finalize pass would
|
|
1039
|
+
* re-enter (and duplicate) the in-progress entity's finalize. A whole-registry
|
|
1040
|
+
* rule instead reasons over the already-finalized peers plus the entity
|
|
1041
|
+
* currently being checked; any conflicting pair is caught when the later of the
|
|
1042
|
+
* two finalizes (both are present by then).
|
|
1043
|
+
*
|
|
1044
|
+
* @internal — for cross-entity linter rules.
|
|
1045
|
+
*/
|
|
1046
|
+
static getFinalized() {
|
|
1047
|
+
const out = /* @__PURE__ */ new Map();
|
|
1048
|
+
for (const [target, meta] of this.store) {
|
|
1049
|
+
if (meta._finalized) out.set(target, meta);
|
|
1050
|
+
}
|
|
1051
|
+
return out;
|
|
1052
|
+
}
|
|
912
1053
|
static get linter() {
|
|
913
1054
|
return this._linter;
|
|
914
1055
|
}
|
|
@@ -968,20 +1109,78 @@ ${messages}`);
|
|
|
968
1109
|
}
|
|
969
1110
|
};
|
|
970
1111
|
|
|
971
|
-
// src/
|
|
972
|
-
var
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
1112
|
+
// src/types/select-builder.ts
|
|
1113
|
+
var SELECT_BUILDER = /* @__PURE__ */ Symbol.for("graphddb.selectBuilder");
|
|
1114
|
+
var SELECT_SPEC = /* @__PURE__ */ Symbol.for("graphddb.selectSpec");
|
|
1115
|
+
|
|
1116
|
+
// src/select/builder.ts
|
|
1117
|
+
var BuilderImpl = class _BuilderImpl {
|
|
1118
|
+
[SELECT_BUILDER] = true;
|
|
1119
|
+
/** The resolved spec, exposed (non-method) so normalizers can read it. */
|
|
1120
|
+
[SELECT_SPEC];
|
|
1121
|
+
constructor(spec) {
|
|
1122
|
+
this[SELECT_SPEC] = spec;
|
|
976
1123
|
}
|
|
977
|
-
|
|
978
|
-
return this
|
|
1124
|
+
clone(patch) {
|
|
1125
|
+
return new _BuilderImpl({ ...this[SELECT_SPEC], ...patch });
|
|
979
1126
|
}
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
1127
|
+
filter(filter) {
|
|
1128
|
+
return this.clone({ filter });
|
|
1129
|
+
}
|
|
1130
|
+
limit(limit) {
|
|
1131
|
+
return this.clone({ limit });
|
|
1132
|
+
}
|
|
1133
|
+
after(cursor) {
|
|
1134
|
+
return this.clone({ after: cursor });
|
|
1135
|
+
}
|
|
1136
|
+
order(order) {
|
|
1137
|
+
return this.clone({ order });
|
|
983
1138
|
}
|
|
984
1139
|
};
|
|
1140
|
+
function isSelectBuilder(value) {
|
|
1141
|
+
return typeof value === "object" && value !== null && value[SELECT_BUILDER] === true;
|
|
1142
|
+
}
|
|
1143
|
+
function fromBuilder(value) {
|
|
1144
|
+
const spec = value[SELECT_SPEC];
|
|
1145
|
+
return {
|
|
1146
|
+
select: spec.select,
|
|
1147
|
+
filter: spec.filter,
|
|
1148
|
+
limit: spec.limit,
|
|
1149
|
+
after: spec.after,
|
|
1150
|
+
order: spec.order
|
|
1151
|
+
};
|
|
1152
|
+
}
|
|
1153
|
+
function normalizeSelectSpec(value) {
|
|
1154
|
+
if (isSelectBuilder(value)) {
|
|
1155
|
+
return fromBuilder(value);
|
|
1156
|
+
}
|
|
1157
|
+
const obj = value;
|
|
1158
|
+
return {
|
|
1159
|
+
select: obj.select,
|
|
1160
|
+
filter: obj.filter,
|
|
1161
|
+
limit: obj.limit,
|
|
1162
|
+
after: obj.after,
|
|
1163
|
+
order: obj.order
|
|
1164
|
+
};
|
|
1165
|
+
}
|
|
1166
|
+
function normalizeTopLevelSelect(value) {
|
|
1167
|
+
if (isSelectBuilder(value)) {
|
|
1168
|
+
return fromBuilder(value);
|
|
1169
|
+
}
|
|
1170
|
+
return { select: value ?? {} };
|
|
1171
|
+
}
|
|
1172
|
+
function buildProject(select) {
|
|
1173
|
+
return new BuilderImpl({
|
|
1174
|
+
[SELECT_BUILDER]: true,
|
|
1175
|
+
select
|
|
1176
|
+
});
|
|
1177
|
+
}
|
|
1178
|
+
function buildRelation(select) {
|
|
1179
|
+
return new BuilderImpl({
|
|
1180
|
+
[SELECT_BUILDER]: true,
|
|
1181
|
+
select
|
|
1182
|
+
});
|
|
1183
|
+
}
|
|
985
1184
|
|
|
986
1185
|
// src/define/param.ts
|
|
987
1186
|
function isParamOptions(value) {
|
|
@@ -2334,14 +2533,14 @@ var MiddlewareRuntime = class {
|
|
|
2334
2533
|
* is the host's responsibility — a hook recovering a `query` should return an
|
|
2335
2534
|
* item / `null`, one recovering a `list` a `{ items, cursor }`.
|
|
2336
2535
|
*/
|
|
2337
|
-
async runRequestError(ctx,
|
|
2536
|
+
async runRequestError(ctx, err4) {
|
|
2338
2537
|
for (let i = this.chain.length - 1; i >= 0; i--) {
|
|
2339
2538
|
const onError = this.chain[i].read?.onError;
|
|
2340
2539
|
if (!onError) continue;
|
|
2341
|
-
const recovered = await onError(ctx,
|
|
2540
|
+
const recovered = await onError(ctx, err4);
|
|
2342
2541
|
if (recovered !== void 0) return recovered;
|
|
2343
2542
|
}
|
|
2344
|
-
throw
|
|
2543
|
+
throw err4;
|
|
2345
2544
|
}
|
|
2346
2545
|
/**
|
|
2347
2546
|
* Drive ONE physical op (root read or any fan-out fetch) through R2 → send →
|
|
@@ -2381,14 +2580,14 @@ var MiddlewareRuntime = class {
|
|
|
2381
2580
|
if (afterFetch) items = await afterFetch(ctx, items);
|
|
2382
2581
|
}
|
|
2383
2582
|
return items;
|
|
2384
|
-
} catch (
|
|
2583
|
+
} catch (err4) {
|
|
2385
2584
|
for (let i = this.chain.length - 1; i >= 0; i--) {
|
|
2386
2585
|
const onError = this.chain[i].read?.op?.onError;
|
|
2387
2586
|
if (!onError) continue;
|
|
2388
|
-
const recovered = await onError(ctx,
|
|
2587
|
+
const recovered = await onError(ctx, err4);
|
|
2389
2588
|
if (recovered !== void 0) return recovered;
|
|
2390
2589
|
}
|
|
2391
|
-
throw
|
|
2590
|
+
throw err4;
|
|
2392
2591
|
}
|
|
2393
2592
|
}
|
|
2394
2593
|
};
|
|
@@ -2452,14 +2651,14 @@ var WriteRuntime = class {
|
|
|
2452
2651
|
* short-circuits the chain); the value becomes the write's resolved value.
|
|
2453
2652
|
* Otherwise the original error rethrows (symmetric with the read `onError`).
|
|
2454
2653
|
*/
|
|
2455
|
-
async runWriteError(ctx,
|
|
2654
|
+
async runWriteError(ctx, err4) {
|
|
2456
2655
|
for (let i = this.chain.length - 1; i >= 0; i--) {
|
|
2457
2656
|
const onError = this.chain[i].write?.onError;
|
|
2458
2657
|
if (!onError) continue;
|
|
2459
|
-
const recovered = await onError(ctx,
|
|
2658
|
+
const recovered = await onError(ctx, err4);
|
|
2460
2659
|
if (recovered !== void 0) return recovered;
|
|
2461
2660
|
}
|
|
2462
|
-
throw
|
|
2661
|
+
throw err4;
|
|
2463
2662
|
}
|
|
2464
2663
|
/** Build a W3/W4/W5 persist context (pure — no hooks run). */
|
|
2465
2664
|
persistCtx(items, origins, transaction) {
|
|
@@ -2488,20 +2687,20 @@ var WriteRuntime = class {
|
|
|
2488
2687
|
if (before) await before(ctx);
|
|
2489
2688
|
}
|
|
2490
2689
|
results = await send(ctx.items);
|
|
2491
|
-
} catch (
|
|
2690
|
+
} catch (err4) {
|
|
2492
2691
|
let recovered;
|
|
2493
2692
|
let didRecover = false;
|
|
2494
2693
|
for (let i = this.chain.length - 1; i >= 0; i--) {
|
|
2495
2694
|
const onError = this.chain[i].write?.persist?.onError;
|
|
2496
2695
|
if (!onError) continue;
|
|
2497
|
-
const r = await onError(ctx,
|
|
2696
|
+
const r = await onError(ctx, err4);
|
|
2498
2697
|
if (r !== void 0) {
|
|
2499
2698
|
recovered = r;
|
|
2500
2699
|
didRecover = true;
|
|
2501
2700
|
break;
|
|
2502
2701
|
}
|
|
2503
2702
|
}
|
|
2504
|
-
if (!didRecover) throw
|
|
2703
|
+
if (!didRecover) throw err4;
|
|
2505
2704
|
results = recovered;
|
|
2506
2705
|
}
|
|
2507
2706
|
for (let i = this.chain.length - 1; i >= 0; i--) {
|
|
@@ -2722,8 +2921,8 @@ async function executeSingleWriteWithHooks(req, mw) {
|
|
|
2722
2921
|
await mw.runWriteBefore(ctx);
|
|
2723
2922
|
const change2 = await persistRewrittenWrite(req.modelClass, ctx, mw);
|
|
2724
2923
|
await mw.runWriteAfter(ctx, change2);
|
|
2725
|
-
} catch (
|
|
2726
|
-
await mw.runWriteError(ctx,
|
|
2924
|
+
} catch (err4) {
|
|
2925
|
+
await mw.runWriteError(ctx, err4);
|
|
2727
2926
|
}
|
|
2728
2927
|
}
|
|
2729
2928
|
async function persistRewrittenWrite(modelClass, ctx, mw) {
|
|
@@ -3437,6 +3636,80 @@ function buildLogicalItem(modelClass, ctx) {
|
|
|
3437
3636
|
return { Delete: buildDeleteInput(modelClass, ctx.input.key ?? {}, opts) };
|
|
3438
3637
|
}
|
|
3439
3638
|
|
|
3639
|
+
// src/hydrator/hydrator.ts
|
|
3640
|
+
function hydrate(rawItems, select, metadata, updatable = false) {
|
|
3641
|
+
return rawItems.map((item) => hydrateItem(item, select, metadata, updatable));
|
|
3642
|
+
}
|
|
3643
|
+
function hydrateItem(raw, select, metadata, updatable = false) {
|
|
3644
|
+
const fieldMap = new Map(
|
|
3645
|
+
metadata.fields.map((f) => [f.propertyName, f])
|
|
3646
|
+
);
|
|
3647
|
+
const embeddedMap = new Map(
|
|
3648
|
+
metadata.embeddedFields.map((e) => [e.propertyName, e])
|
|
3649
|
+
);
|
|
3650
|
+
const result = {};
|
|
3651
|
+
for (const [fieldName, selectValue] of Object.entries(select)) {
|
|
3652
|
+
if (selectValue === true) {
|
|
3653
|
+
const value = raw[fieldName];
|
|
3654
|
+
if (value !== void 0) {
|
|
3655
|
+
const fieldMeta = fieldMap.get(fieldName);
|
|
3656
|
+
result[fieldName] = deserializeValue(value, fieldMeta);
|
|
3657
|
+
}
|
|
3658
|
+
} else if (typeof selectValue === "object" && selectValue !== null) {
|
|
3659
|
+
if ("select" in selectValue || isSelectBuilder(selectValue)) {
|
|
3660
|
+
continue;
|
|
3661
|
+
}
|
|
3662
|
+
const rawEmb = raw[fieldName];
|
|
3663
|
+
if (rawEmb && typeof rawEmb === "object") {
|
|
3664
|
+
result[fieldName] = hydrateEmbedded(
|
|
3665
|
+
rawEmb,
|
|
3666
|
+
selectValue,
|
|
3667
|
+
metadata,
|
|
3668
|
+
fieldName
|
|
3669
|
+
);
|
|
3670
|
+
}
|
|
3671
|
+
}
|
|
3672
|
+
}
|
|
3673
|
+
if (updatable) {
|
|
3674
|
+
attachHiddenKey(result, raw);
|
|
3675
|
+
}
|
|
3676
|
+
return result;
|
|
3677
|
+
}
|
|
3678
|
+
function hydrateEmbedded(raw, select, _metadata, _fieldName) {
|
|
3679
|
+
const result = {};
|
|
3680
|
+
for (const [key2, selectValue] of Object.entries(select)) {
|
|
3681
|
+
if (selectValue === true) {
|
|
3682
|
+
if (raw[key2] !== void 0) {
|
|
3683
|
+
result[key2] = raw[key2];
|
|
3684
|
+
}
|
|
3685
|
+
} else if (typeof selectValue === "object" && selectValue !== null && !("select" in selectValue)) {
|
|
3686
|
+
const nested = raw[key2];
|
|
3687
|
+
if (nested && typeof nested === "object") {
|
|
3688
|
+
result[key2] = hydrateEmbedded(
|
|
3689
|
+
nested,
|
|
3690
|
+
selectValue,
|
|
3691
|
+
_metadata,
|
|
3692
|
+
key2
|
|
3693
|
+
);
|
|
3694
|
+
}
|
|
3695
|
+
}
|
|
3696
|
+
}
|
|
3697
|
+
return result;
|
|
3698
|
+
}
|
|
3699
|
+
function deserializeValue(value, fieldMeta) {
|
|
3700
|
+
if (!fieldMeta) return value;
|
|
3701
|
+
if (fieldMeta.options?.deserialize) {
|
|
3702
|
+
return fieldMeta.options.deserialize(value);
|
|
3703
|
+
}
|
|
3704
|
+
if (fieldMeta.options?.format === "datetime" && typeof value === "string") {
|
|
3705
|
+
return new Date(value);
|
|
3706
|
+
}
|
|
3707
|
+
if (fieldMeta.options?.format === "date" && typeof value === "string") {
|
|
3708
|
+
return /* @__PURE__ */ new Date(value + "T00:00:00.000Z");
|
|
3709
|
+
}
|
|
3710
|
+
return value;
|
|
3711
|
+
}
|
|
3712
|
+
|
|
3440
3713
|
// src/define/entity-writes.ts
|
|
3441
3714
|
var MAINTAIN_TRIGGER_RE = /^[^.\s$[\]*]+\.(?:created|updated|removed)$/;
|
|
3442
3715
|
function maintainTrigger(value) {
|
|
@@ -4272,6 +4545,100 @@ function buildMaintenanceGraphImpl(registry, views) {
|
|
|
4272
4545
|
};
|
|
4273
4546
|
}
|
|
4274
4547
|
|
|
4548
|
+
// src/cdc/from-change.ts
|
|
4549
|
+
function buildFullSelect(metadata) {
|
|
4550
|
+
const select = {};
|
|
4551
|
+
for (const field of metadata.fields) {
|
|
4552
|
+
select[field.propertyName] = true;
|
|
4553
|
+
}
|
|
4554
|
+
return select;
|
|
4555
|
+
}
|
|
4556
|
+
function eventOwnedBy(event, metadata, modelName) {
|
|
4557
|
+
if (event.model !== void 0) {
|
|
4558
|
+
return event.model === modelName;
|
|
4559
|
+
}
|
|
4560
|
+
return metadata.prefix.length > 0 && event.keys.pk.startsWith(metadata.prefix);
|
|
4561
|
+
}
|
|
4562
|
+
function hydrateImage(image, select, metadata, modelClass) {
|
|
4563
|
+
if (image === void 0) return null;
|
|
4564
|
+
const [hydrated] = hydrate([image], select, metadata);
|
|
4565
|
+
const instance = new modelClass();
|
|
4566
|
+
Object.assign(instance, hydrated);
|
|
4567
|
+
for (const emb of metadata.embeddedFields) {
|
|
4568
|
+
const rawEmb = image[emb.propertyName];
|
|
4569
|
+
if (rawEmb !== void 0 && rawEmb !== null) {
|
|
4570
|
+
instance[emb.propertyName] = rawEmb;
|
|
4571
|
+
}
|
|
4572
|
+
}
|
|
4573
|
+
return instance;
|
|
4574
|
+
}
|
|
4575
|
+
function parseChange(event, metadata, modelName, modelClass) {
|
|
4576
|
+
if (!eventOwnedBy(event, metadata, modelName)) {
|
|
4577
|
+
return [null, null];
|
|
4578
|
+
}
|
|
4579
|
+
const select = buildFullSelect(metadata);
|
|
4580
|
+
const oldRecord = hydrateImage(event.oldImage, select, metadata, modelClass);
|
|
4581
|
+
const newRecord = hydrateImage(event.newImage, select, metadata, modelClass);
|
|
4582
|
+
return [oldRecord, newRecord];
|
|
4583
|
+
}
|
|
4584
|
+
|
|
4585
|
+
// src/cdc/subscribe.ts
|
|
4586
|
+
function resolveRoutableModels(handlers) {
|
|
4587
|
+
const byName = /* @__PURE__ */ new Map();
|
|
4588
|
+
for (const [cls, meta] of MetadataRegistry.getAll()) {
|
|
4589
|
+
byName.set(cls.name, { cls, meta });
|
|
4590
|
+
}
|
|
4591
|
+
const routable = /* @__PURE__ */ new Map();
|
|
4592
|
+
for (const modelName of Object.keys(handlers)) {
|
|
4593
|
+
const entry = byName.get(modelName);
|
|
4594
|
+
if (!entry) {
|
|
4595
|
+
throw new Error(
|
|
4596
|
+
`DDBModel.subscribe: no model named '${modelName}' is registered. Did you import the model module (so its @model decorator ran) and spell the handler key exactly as the class name?`
|
|
4597
|
+
);
|
|
4598
|
+
}
|
|
4599
|
+
if (!entry.meta.cdcProjected) {
|
|
4600
|
+
throw new Error(
|
|
4601
|
+
`DDBModel.subscribe: model '${modelName}' is not @cdcProjected, so its change events cannot be parsed. Add @cdcProjected() to the model, or remove its handler.`
|
|
4602
|
+
);
|
|
4603
|
+
}
|
|
4604
|
+
routable.set(modelName, {
|
|
4605
|
+
modelClass: entry.cls,
|
|
4606
|
+
metadata: entry.meta,
|
|
4607
|
+
modelName
|
|
4608
|
+
});
|
|
4609
|
+
}
|
|
4610
|
+
return routable;
|
|
4611
|
+
}
|
|
4612
|
+
async function dispatchEvent(event, routable, handlers) {
|
|
4613
|
+
for (const { modelClass, metadata, modelName } of routable.values()) {
|
|
4614
|
+
const [oldRecord, newRecord] = parseChange(
|
|
4615
|
+
event,
|
|
4616
|
+
metadata,
|
|
4617
|
+
modelName,
|
|
4618
|
+
modelClass
|
|
4619
|
+
);
|
|
4620
|
+
if (oldRecord === null && newRecord === null) continue;
|
|
4621
|
+
const handler = handlers[modelName];
|
|
4622
|
+
await handler(oldRecord, newRecord);
|
|
4623
|
+
return true;
|
|
4624
|
+
}
|
|
4625
|
+
return false;
|
|
4626
|
+
}
|
|
4627
|
+
function buildSubscribeHandler(handlers) {
|
|
4628
|
+
const routable = resolveRoutableModels(handlers);
|
|
4629
|
+
return async (batch) => {
|
|
4630
|
+
const batchItemFailures = [];
|
|
4631
|
+
for (const event of batch.records) {
|
|
4632
|
+
try {
|
|
4633
|
+
await dispatchEvent(event, routable, handlers);
|
|
4634
|
+
} catch {
|
|
4635
|
+
batchItemFailures.push(event.sequenceNumber);
|
|
4636
|
+
}
|
|
4637
|
+
}
|
|
4638
|
+
return { batchItemFailures };
|
|
4639
|
+
};
|
|
4640
|
+
}
|
|
4641
|
+
|
|
4275
4642
|
export {
|
|
4276
4643
|
isColumn,
|
|
4277
4644
|
createColumnMap,
|
|
@@ -4287,12 +4654,17 @@ export {
|
|
|
4287
4654
|
resolveKey,
|
|
4288
4655
|
missingGsiRule,
|
|
4289
4656
|
relationDepthRule,
|
|
4657
|
+
TableMapping,
|
|
4290
4658
|
createDefaultLinter,
|
|
4291
4659
|
MetadataRegistry,
|
|
4292
|
-
TableMapping,
|
|
4293
4660
|
pkTemplate,
|
|
4294
4661
|
skTemplate,
|
|
4295
4662
|
resolveSegmentedKey,
|
|
4663
|
+
isSelectBuilder,
|
|
4664
|
+
normalizeSelectSpec,
|
|
4665
|
+
normalizeTopLevelSelect,
|
|
4666
|
+
buildProject,
|
|
4667
|
+
buildRelation,
|
|
4296
4668
|
param,
|
|
4297
4669
|
isParam,
|
|
4298
4670
|
BATCH_GET_MAX_KEYS,
|
|
@@ -4335,6 +4707,7 @@ export {
|
|
|
4335
4707
|
resolveModelClass,
|
|
4336
4708
|
TransactionContext,
|
|
4337
4709
|
executeTransaction,
|
|
4710
|
+
hydrate,
|
|
4338
4711
|
maintainTrigger,
|
|
4339
4712
|
isMaintainTrigger,
|
|
4340
4713
|
preview,
|
|
@@ -4347,5 +4720,7 @@ export {
|
|
|
4347
4720
|
getEntityWrites,
|
|
4348
4721
|
lifecyclePhaseForIntent,
|
|
4349
4722
|
collectViewDefinitions,
|
|
4350
|
-
buildMaintenanceGraph
|
|
4723
|
+
buildMaintenanceGraph,
|
|
4724
|
+
parseChange,
|
|
4725
|
+
buildSubscribeHandler
|
|
4351
4726
|
};
|