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 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 (Epic #118): `pattern: 'embeddedSnapshot'` keeps a denormalized collection / snapshot synchronized onto the owner row in the same atomic transaction as the source write. |
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/client/TableMapping.ts
972
- var TableMapping = class {
973
- static mapping = {};
974
- static set(mapping) {
975
- this.mapping = { ...mapping };
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
- static resolve(declaredName) {
978
- return this.mapping[declaredName] ?? declaredName;
1124
+ clone(patch) {
1125
+ return new _BuilderImpl({ ...this[SELECT_SPEC], ...patch });
979
1126
  }
980
- /** @internal — for testing only */
981
- static reset() {
982
- this.mapping = {};
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, err3) {
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, err3);
2540
+ const recovered = await onError(ctx, err4);
2342
2541
  if (recovered !== void 0) return recovered;
2343
2542
  }
2344
- throw err3;
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 (err3) {
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, err3);
2587
+ const recovered = await onError(ctx, err4);
2389
2588
  if (recovered !== void 0) return recovered;
2390
2589
  }
2391
- throw err3;
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, err3) {
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, err3);
2658
+ const recovered = await onError(ctx, err4);
2460
2659
  if (recovered !== void 0) return recovered;
2461
2660
  }
2462
- throw err3;
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 (err3) {
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, err3);
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 err3;
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 (err3) {
2726
- await mw.runWriteError(ctx, err3);
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
  };
@@ -7,7 +7,7 @@ import {
7
7
  buildMaintenanceGraph,
8
8
  buildPutInput,
9
9
  resolveModelClass
10
- } from "./chunk-CPTV3H2U.js";
10
+ } from "./chunk-B3GWIWT6.js";
11
11
 
12
12
  // src/cdc/prng.ts
13
13
  var SeededRandom = class {