graphddb 0.3.0 → 0.4.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/dist/index.js CHANGED
@@ -2,8 +2,6 @@ import {
2
2
  BatchGetResult,
3
3
  DDBModel,
4
4
  EDGE_WRITES_MARKER,
5
- ENTITY_WRITES_MARKER,
6
- LIFECYCLE_CONTRACT_MARKER,
7
5
  MARKER_ROW_ENTITY,
8
6
  OLD_VALUE_NAMESPACE,
9
7
  RELATION_TRAVERSAL_CONCURRENCY,
@@ -16,7 +14,6 @@ import {
16
14
  buildBridgeBundle,
17
15
  buildContexts,
18
16
  buildContracts,
19
- buildMaintenanceGraph,
20
17
  buildManifest,
21
18
  buildOperations,
22
19
  buildProjection,
@@ -42,7 +39,6 @@ import {
42
39
  detectRelationFields,
43
40
  edgeWrites,
44
41
  encodeCursor,
45
- entityWrites,
46
42
  evaluateFilter,
47
43
  execute,
48
44
  executeBatchGet,
@@ -54,10 +50,8 @@ import {
54
50
  executeQuery,
55
51
  from,
56
52
  getEdgeWrites,
57
- getEntityWrites,
58
53
  getImplicitKeyFields,
59
54
  hydrate,
60
- identity,
61
55
  isCommandModelContract,
62
56
  isCommandPlan,
63
57
  isContractComposeNode,
@@ -66,23 +60,17 @@ import {
66
60
  isContractKeyRef,
67
61
  isContractParamRef,
68
62
  isEdgeWritesDefinition,
69
- isEntityWritesDefinition,
70
- isLifecycleContract,
71
- isMaintainTrigger,
72
63
  isMutationFragment,
73
64
  isMutationInputRef,
74
65
  isPlannedCommandMethod,
75
66
  isQueryModelContract,
76
67
  isSelectBuilder,
77
68
  isTransactionRef,
78
- lifecyclePhaseForIntent,
79
- maintainTrigger,
80
69
  mapWithConcurrency,
81
70
  mintContractKeyFieldRef,
82
71
  mintContractParamRef,
83
72
  mutation,
84
73
  plan,
85
- preview,
86
74
  publicCommandModel,
87
75
  publicQueryModel,
88
76
  query,
@@ -91,11 +79,19 @@ import {
91
79
  validateDepth,
92
80
  when,
93
81
  wholeKeysSentinel
94
- } from "./chunk-QEOFIXTN.js";
82
+ } from "./chunk-PC54CW5P.js";
95
83
  import {
96
84
  CdcEmulator,
97
- createCdcEmulator
98
- } from "./chunk-QQNP43JL.js";
85
+ MAINT_OUTBOX_PK_PREFIX,
86
+ MaintenanceDrain,
87
+ compareDesc,
88
+ createCdcEmulator,
89
+ createMaintenanceDrain,
90
+ createMaintenanceDrainHandler,
91
+ orderAndTrimCollection,
92
+ pathField,
93
+ projectFrom
94
+ } from "./chunk-GWFZVNAV.js";
99
95
  import {
100
96
  BATCH_GET_MAX_KEYS,
101
97
  BATCH_WRITE_MAX_ITEMS,
@@ -103,6 +99,8 @@ import {
103
99
  DEFAULT_MAX_ATTEMPTS,
104
100
  DEFAULT_RETRY_POLICY,
105
101
  DynamoExecutor,
102
+ ENTITY_WRITES_MARKER,
103
+ LIFECYCLE_CONTRACT_MARKER,
106
104
  Linter,
107
105
  MAX_TRANSACT_ITEMS,
108
106
  MetadataRegistry,
@@ -112,39 +110,51 @@ import {
112
110
  attachModelClass,
113
111
  buildConditionExpression,
114
112
  buildDeleteInput,
113
+ buildMaintenanceGraph,
115
114
  buildPutInput,
116
115
  buildUpdateExpression,
117
116
  buildUpdateInput,
118
117
  collapseSameKeyItems,
119
118
  collectRawConditionParams,
119
+ collectViewDefinitions,
120
120
  commitTransaction,
121
121
  cond,
122
122
  createDefaultLinter,
123
+ entityWrites,
123
124
  execItemKeySignature,
124
125
  executeDelete,
125
126
  executePut,
126
127
  executeTransaction,
127
128
  executeUpdate,
129
+ getEntityWrites,
128
130
  gsi,
129
131
  gsiAmbiguityRule,
132
+ identity,
130
133
  isColumn,
134
+ isEntityWritesDefinition,
131
135
  isKeySegment,
136
+ isLifecycleContract,
137
+ isMaintainTrigger,
132
138
  isParam,
133
139
  isRawCondition,
134
140
  isRetryableError,
135
141
  isRetryableTransactionCancellation,
136
142
  k,
137
143
  key,
144
+ lifecyclePhaseForIntent,
145
+ maintainTrigger,
138
146
  missingGsiRule,
139
147
  noScanRule,
140
148
  param,
149
+ preview,
141
150
  relationDepthRule,
142
151
  requireLimitRule,
143
152
  resolveConditionTree,
144
153
  resolveKey,
145
154
  resolveModelClass,
155
+ segmentFieldNames,
146
156
  serializeFieldValue
147
- } from "./chunk-6AIAHP3A.js";
157
+ } from "./chunk-72VZYOSG.js";
148
158
 
149
159
  // src/metadata/prefix.ts
150
160
  function derivePrefix(customPrefix, className) {
@@ -195,6 +205,7 @@ var pendingFields = [];
195
205
  var pendingEmbedded = [];
196
206
  var pendingRelations = [];
197
207
  var pendingAggregates = [];
208
+ var pendingMaintainedFrom = [];
198
209
  function collectField(field2) {
199
210
  pendingFields.push(field2);
200
211
  }
@@ -207,6 +218,9 @@ function collectRelation(relation) {
207
218
  function collectAggregate(aggregate2) {
208
219
  pendingAggregates.push(aggregate2);
209
220
  }
221
+ function collectMaintainedFrom(decl) {
222
+ pendingMaintainedFrom.push(decl);
223
+ }
210
224
  function drainFields() {
211
225
  const result = pendingFields;
212
226
  pendingFields = [];
@@ -227,6 +241,11 @@ function drainAggregates() {
227
241
  pendingAggregates = [];
228
242
  return result;
229
243
  }
244
+ function drainMaintainedFrom() {
245
+ const result = pendingMaintainedFrom;
246
+ pendingMaintainedFrom = [];
247
+ return result;
248
+ }
230
249
 
231
250
  // src/decorators/model.ts
232
251
  function model(options) {
@@ -235,7 +254,19 @@ function model(options) {
235
254
  const embeddedFields = drainEmbedded();
236
255
  const relations = drainRelations();
237
256
  const aggregates = drainAggregates();
257
+ const maintainedFrom2 = drainMaintainedFrom();
238
258
  const prefix = derivePrefix(options.prefix, context.name);
259
+ const kind = options.kind ?? "entity";
260
+ if (kind === "entity" && maintainedFrom2.length > 0) {
261
+ throw new Error(
262
+ `@model on '${String(context.name)}': ${maintainedFrom2.length} \`@maintainedFrom\` declaration(s) are present but \`kind\` is 'entity' (the default). A maintained view must declare \`@model({ kind: 'materializedView' | 'sparseView' })\` \u2014 a plain entity does not carry view maintainers.`
263
+ );
264
+ }
265
+ if (kind !== "entity" && maintainedFrom2.length === 0) {
266
+ throw new Error(
267
+ `@model on '${String(context.name)}': \`kind: '${kind}'\` declares a maintained view but no \`@maintainedFrom(...)\` source is stacked. A view with no source is maintained by nothing \u2014 add at least one \`@maintainedFrom\` decorator.`
268
+ );
269
+ }
239
270
  MetadataRegistry.register(target, {
240
271
  tableName: options.table,
241
272
  prefix,
@@ -244,7 +275,9 @@ function model(options) {
244
275
  gsiDefinitions: [],
245
276
  relations,
246
277
  aggregates,
247
- embeddedFields
278
+ embeddedFields,
279
+ kind,
280
+ maintainedFrom: maintainedFrom2
248
281
  });
249
282
  };
250
283
  }
@@ -314,7 +347,95 @@ function embedded(modelFactory) {
314
347
  };
315
348
  }
316
349
 
350
+ // src/decorators/maintained-symbolic.ts
351
+ var SOURCE_REF = /* @__PURE__ */ Symbol("graphddb:maintainedSourceRef");
352
+ var SELF_REF = /* @__PURE__ */ Symbol("graphddb:maintainedSelfRef");
353
+ function isSourceRef(value) {
354
+ return typeof value === "object" && value !== null && value[SOURCE_REF] === true;
355
+ }
356
+ function isSelfRef(value) {
357
+ return typeof value === "object" && value !== null && value[SELF_REF] === true;
358
+ }
359
+ function createSourceProxy() {
360
+ const cache = /* @__PURE__ */ new Map();
361
+ return new Proxy(
362
+ {},
363
+ {
364
+ get(_t, prop) {
365
+ if (typeof prop !== "string") return void 0;
366
+ let ref = cache.get(prop);
367
+ if (!ref) {
368
+ ref = { [SOURCE_REF]: true, field: prop, path: `$.entity.${prop}` };
369
+ cache.set(prop, ref);
370
+ }
371
+ return ref;
372
+ }
373
+ }
374
+ );
375
+ }
376
+ function createSelfProxy() {
377
+ const cache = /* @__PURE__ */ new Map();
378
+ return new Proxy(
379
+ {},
380
+ {
381
+ get(_t, prop) {
382
+ if (typeof prop !== "string") return void 0;
383
+ let ref = cache.get(prop);
384
+ if (!ref) {
385
+ ref = { [SELF_REF]: true, field: prop };
386
+ cache.set(prop, ref);
387
+ }
388
+ return ref;
389
+ }
390
+ }
391
+ );
392
+ }
393
+
317
394
  // src/decorators/relation.ts
395
+ function lowerVersionedProjectionValue(propertyName, attr, value) {
396
+ if (isSourceRef(value)) return identity(value.path);
397
+ if (value && typeof value === "object" && value.kind === "transform") {
398
+ return value;
399
+ }
400
+ throw new Error(
401
+ `@hasOne/@hasMany versioned relation '${propertyName}': projection target '${attr}' must be a \`source.<field>\` reference or a transform (e.g. \`preview(source.body, 120)\`), got ${JSON.stringify(value)}.`
402
+ );
403
+ }
404
+ function lowerVersionedOptions(propertyName, opts) {
405
+ if (!opts || typeof opts !== "object" || opts.pattern !== "versionedLatest" && opts.pattern !== "versionedHistory") {
406
+ throw new Error(
407
+ `@hasOne/@hasMany versioned relation '${propertyName}': the (self, source) => options callback must return \`{ pattern: 'versionedLatest' | 'versionedHistory', on, project }\`.`
408
+ );
409
+ }
410
+ if (!Array.isArray(opts.on) || opts.on.length === 0) {
411
+ throw new Error(
412
+ `@hasOne/@hasMany versioned relation '${propertyName}': \`on\` must be a non-empty array of lifecycle events (e.g. \`['created', 'updated']\`).`
413
+ );
414
+ }
415
+ const projectEntries = Object.entries(opts.project ?? {});
416
+ if (projectEntries.length === 0) {
417
+ throw new Error(
418
+ `@hasOne/@hasMany versioned relation '${propertyName}': \`project\` is empty \u2014 a versioned row with no captured attributes projects nothing.`
419
+ );
420
+ }
421
+ const project = {};
422
+ for (const [attr, value] of projectEntries) {
423
+ project[attr] = lowerVersionedProjectionValue(propertyName, attr, value);
424
+ }
425
+ return {
426
+ pattern: opts.pattern,
427
+ versioned: {
428
+ mode: opts.pattern === "versionedLatest" ? "latest" : "history",
429
+ on: [...opts.on],
430
+ project,
431
+ ...opts.consistency !== void 0 ? { consistency: opts.consistency } : {},
432
+ ...opts.updateMode !== void 0 ? { updateMode: opts.updateMode } : {}
433
+ }
434
+ };
435
+ }
436
+ function isVersionedCallback(arg) {
437
+ return typeof arg === "function";
438
+ }
318
439
  var RESERVED_RELATION_PROPERTY_NAMES = /* @__PURE__ */ new Set([
319
440
  "items"
320
441
  ]);
@@ -325,15 +446,34 @@ function assertRelationPropertyNameAllowed(propertyName) {
325
446
  );
326
447
  }
327
448
  }
328
- function hasMany(targetFactory, keyBinding, options) {
449
+ function hasMany(targetFactory, keyBindingOrCallback, options) {
329
450
  return function(_value, context) {
330
451
  const propertyName = String(context.name);
331
452
  assertRelationPropertyNameAllowed(propertyName);
453
+ if (isVersionedCallback(keyBindingOrCallback)) {
454
+ const opts = keyBindingOrCallback(
455
+ createSelfProxy(),
456
+ createSourceProxy()
457
+ );
458
+ if (opts.pattern !== "versionedHistory") {
459
+ throw new Error(
460
+ `@hasMany versioned relation '${propertyName}': a \`@hasMany\` maintains an append-only HISTORY, so its pattern must be 'versionedHistory' (got '${opts.pattern}'). Use \`@hasOne\` for 'versionedLatest' (the single overwritten pointer).`
461
+ );
462
+ }
463
+ collectRelation({
464
+ type: "hasMany",
465
+ propertyName,
466
+ targetFactory,
467
+ keyBinding: {},
468
+ options: lowerVersionedOptions(propertyName, opts)
469
+ });
470
+ return;
471
+ }
332
472
  collectRelation({
333
473
  type: "hasMany",
334
474
  propertyName,
335
475
  targetFactory,
336
- keyBinding,
476
+ keyBinding: keyBindingOrCallback,
337
477
  options
338
478
  });
339
479
  };
@@ -351,15 +491,34 @@ function belongsTo(targetFactory, keyBinding, options) {
351
491
  });
352
492
  };
353
493
  }
354
- function hasOne(targetFactory, keyBinding) {
494
+ function hasOne(targetFactory, keyBindingOrCallback) {
355
495
  return function(_value, context) {
356
496
  const propertyName = String(context.name);
357
497
  assertRelationPropertyNameAllowed(propertyName);
498
+ if (isVersionedCallback(keyBindingOrCallback)) {
499
+ const opts = keyBindingOrCallback(
500
+ createSelfProxy(),
501
+ createSourceProxy()
502
+ );
503
+ if (opts.pattern !== "versionedLatest") {
504
+ throw new Error(
505
+ `@hasOne versioned relation '${propertyName}': a \`@hasOne\` maintains a single LATEST pointer (overwritten per revision), so its pattern must be 'versionedLatest' (got '${opts.pattern}'). Use \`@hasMany\` for 'versionedHistory' (append-only per-version rows).`
506
+ );
507
+ }
508
+ collectRelation({
509
+ type: "hasOne",
510
+ propertyName,
511
+ targetFactory,
512
+ keyBinding: {},
513
+ options: lowerVersionedOptions(propertyName, opts)
514
+ });
515
+ return;
516
+ }
358
517
  collectRelation({
359
518
  type: "hasOne",
360
519
  propertyName,
361
520
  targetFactory,
362
- keyBinding
521
+ keyBinding: keyBindingOrCallback
363
522
  });
364
523
  };
365
524
  }
@@ -387,6 +546,118 @@ function max(field2) {
387
546
  return { op: "max", field: field2 };
388
547
  }
389
548
 
549
+ // src/decorators/maintained.ts
550
+ function whenMember(operand, op, value) {
551
+ const raw = isSourceRef(operand) ? operand.path : operand;
552
+ const path = raw.startsWith("$.input.") || raw.startsWith("$.entity.") ? raw : `$.entity.${raw}`;
553
+ const needsValue = op === "eq" || op === "ne";
554
+ if (needsValue && value === void 0) {
555
+ throw new Error(
556
+ `whenMember(source.\u2026, '${op}', \u2026): the '${op}' op requires a comparand value (e.g. \`whenMember(source.status, '${op}', 'active')\`).`
557
+ );
558
+ }
559
+ return needsValue ? { path, op, value } : { path, op };
560
+ }
561
+ function lowerProjectionValue(modelName, attr, value) {
562
+ if (isSourceRef(value)) return identity(value.path);
563
+ if (value && typeof value === "object" && value.kind === "transform") {
564
+ return value;
565
+ }
566
+ throw new Error(
567
+ `@maintainedFrom on '${modelName}': projection target '${attr}' must be a \`source.<field>\` reference or a transform (e.g. \`preview(source.body, 120)\`), got ${JSON.stringify(value)}. A maintained projection captures only symbolic source references \u2014 a literal / computed value is not allowed.`
568
+ );
569
+ }
570
+ function lowerKeyBind(modelName, keyBind) {
571
+ const out = {};
572
+ const entries = Object.entries(keyBind);
573
+ if (entries.length === 0) {
574
+ throw new Error(
575
+ `@maintainedFrom on '${modelName}': \`keyBind\` is empty. The view-row key binding (view-key field \u2192 \`source.<field>\`) is required to resolve which view row a source event maintains.`
576
+ );
577
+ }
578
+ for (const [viewKeyField, ref] of entries) {
579
+ if (!isSourceRef(ref)) {
580
+ throw new Error(
581
+ `@maintainedFrom on '${modelName}': \`keyBind.${viewKeyField}\` must be a \`source.<field>\` reference (e.g. \`{ ${viewKeyField}: source.${viewKeyField} }\`), got ${JSON.stringify(ref)}.`
582
+ );
583
+ }
584
+ out[viewKeyField] = ref.path;
585
+ }
586
+ return out;
587
+ }
588
+ function maintainedFrom(source, callback) {
589
+ return function(target, context) {
590
+ const modelName = String(context.name ?? target.name ?? "(anonymous)");
591
+ const selfProxy = createSelfProxy();
592
+ const sourceProxy = createSourceProxy();
593
+ const opts = callback(selfProxy, sourceProxy);
594
+ if (!opts || typeof opts !== "object") {
595
+ throw new Error(
596
+ `@maintainedFrom on '${modelName}': the (self, source) => options callback must return an options object.`
597
+ );
598
+ }
599
+ if (!Array.isArray(opts.on) || opts.on.length === 0) {
600
+ throw new Error(
601
+ `@maintainedFrom on '${modelName}': \`on\` must be a non-empty array of lifecycle events (e.g. \`['created', 'removed']\`).`
602
+ );
603
+ }
604
+ for (const ev of opts.on) {
605
+ if (ev !== "created" && ev !== "updated" && ev !== "removed") {
606
+ throw new Error(
607
+ `@maintainedFrom on '${modelName}': \`on\` event ${JSON.stringify(ev)} is not one of 'created' / 'updated' / 'removed'.`
608
+ );
609
+ }
610
+ }
611
+ const keyBind = lowerKeyBind(modelName, opts.keyBind);
612
+ const projectEntries = Object.entries(opts.project ?? {});
613
+ if (projectEntries.length === 0) {
614
+ throw new Error(
615
+ `@maintainedFrom on '${modelName}': \`project\` is empty. A maintained view with no captured attributes projects nothing \u2014 declare a \`project\` map (e.g. \`{ title: source.title, textPreview: preview(source.body, 120) }\`).`
616
+ );
617
+ }
618
+ const project = {};
619
+ for (const [attr, value] of projectEntries) {
620
+ project[attr] = lowerProjectionValue(modelName, attr, value);
621
+ }
622
+ if (opts.collection && opts.when) {
623
+ throw new Error(
624
+ `@maintainedFrom on '${modelName}': a declaration carries both \`collection\` and \`when\`. They are mutually exclusive \u2014 a collection slice holds a maintained list; a membership (\`when\`) row appears/disappears as a whole.`
625
+ );
626
+ }
627
+ let collection;
628
+ if (opts.collection) {
629
+ const c = opts.collection;
630
+ if (!isSelfRef(c.field)) {
631
+ throw new Error(
632
+ `@maintainedFrom on '${modelName}': \`collection.field\` must be a \`self.<field>\` reference (the view attribute holding the list, e.g. \`self.latestPosts\`), got ${JSON.stringify(c.field)}.`
633
+ );
634
+ }
635
+ if (c.orderBy !== void 0 && !isSourceRef(c.orderBy)) {
636
+ throw new Error(
637
+ `@maintainedFrom on '${modelName}': \`collection.orderBy\` must be a \`source.<field>\` reference, got ${JSON.stringify(c.orderBy)}.`
638
+ );
639
+ }
640
+ collection = {
641
+ field: c.field.field,
642
+ ...c.maxItems !== void 0 ? { maxItems: c.maxItems } : {},
643
+ ...c.order !== void 0 ? { order: c.order } : {},
644
+ ...c.orderBy !== void 0 ? { orderBy: c.orderBy.path } : {}
645
+ };
646
+ }
647
+ const metadata = {
648
+ sourceFactory: source,
649
+ on: [...opts.on],
650
+ keyBind,
651
+ project,
652
+ ...collection ? { collection } : {},
653
+ ...opts.when ? { when: opts.when } : {},
654
+ ...opts.consistency !== void 0 ? { consistency: opts.consistency } : {},
655
+ ...opts.updateMode !== void 0 ? { updateMode: opts.updateMode } : {}
656
+ };
657
+ collectMaintainedFrom(metadata);
658
+ };
659
+ }
660
+
390
661
  // src/operations/declarative-transaction.ts
391
662
  var PLACEHOLDER_RE = /\{[^{}]+\}/g;
392
663
  function resolveTemplate(template, params, element) {
@@ -955,6 +1226,363 @@ var queryBoundaryRule = {
955
1226
  }
956
1227
  };
957
1228
 
1229
+ // src/relation/maintenance-rebuild.ts
1230
+ function toClass(m) {
1231
+ try {
1232
+ return resolveModelClass(m);
1233
+ } catch {
1234
+ return m;
1235
+ }
1236
+ }
1237
+ var MaintenanceRebuilder = class {
1238
+ graph;
1239
+ constructor(opts = {}) {
1240
+ const classes = (opts.models ?? []).map(toClass);
1241
+ const explicitViews = opts.views;
1242
+ const allClasses = [
1243
+ ...classes,
1244
+ ...(explicitViews ?? []).map((v) => v.viewClass)
1245
+ ];
1246
+ const scoped = allClasses.length > 0 ? new Map(allClasses.map((c) => [c, MetadataRegistry.get(c)])) : void 0;
1247
+ this.graph = buildMaintenanceGraph(scoped, explicitViews);
1248
+ }
1249
+ /**
1250
+ * Re-derive the materialized value for one owner row × maintainer from the source
1251
+ * rows and report whether it differs from what the owner row currently stores. Does
1252
+ * NOT write anything (a read-only drift probe).
1253
+ *
1254
+ * @param owner The owner model class declaring the maintainer.
1255
+ * @param relationProperty The relation / `@aggregate` property the value lives under.
1256
+ * @param ownerKeyInput The owner row's key input (e.g. `{ threadId: 't1' }`).
1257
+ */
1258
+ async detectDrift(owner, relationProperty, ownerKeyInput) {
1259
+ const { item, ownerMeta, ownerKey } = this.resolve(owner, relationProperty);
1260
+ const expected = await this.recompute(item, ownerMeta, ownerKeyInput);
1261
+ const ownerRow = await this.readOwnerRow(ownerMeta, ownerKey(ownerKeyInput));
1262
+ const actual = this.actualValue(item.effect, ownerRow);
1263
+ const ownerAbsentAndEmpty = ownerRow === void 0 && isEmptyMaintained(item.effect, expected);
1264
+ return {
1265
+ relationProperty,
1266
+ ownerEntity: item.ownerEntity,
1267
+ ownerKey: ownerKey(ownerKeyInput),
1268
+ kind: item.effect.kind,
1269
+ drifted: !ownerAbsentAndEmpty && !deepEqual(normalize(expected), normalize(actual)),
1270
+ expected,
1271
+ actual
1272
+ };
1273
+ }
1274
+ /**
1275
+ * Re-derive the materialized value for one owner row × maintainer from the source
1276
+ * rows and WRITE the absolute re-derived value onto the owner row when it drifts (or
1277
+ * always, when `force`). Returns the drift verdict plus whether a repair write ran.
1278
+ *
1279
+ * @param options.force Write even when no drift is detected (default: write only on drift).
1280
+ */
1281
+ async rebuild(owner, relationProperty, ownerKeyInput, options = {}) {
1282
+ const report = await this.detectDrift(owner, relationProperty, ownerKeyInput);
1283
+ if (!report.drifted && options.force !== true) {
1284
+ return { ...report, repaired: false };
1285
+ }
1286
+ const { item, ownerMeta } = this.resolve(owner, relationProperty);
1287
+ await this.writeValue(item.effect, ownerMeta, report.ownerKey, report.expected);
1288
+ return { ...report, repaired: true };
1289
+ }
1290
+ /** Resolve an owner model + property to its {@link MaintainItem} + owner-key builder. */
1291
+ resolve(owner, relationProperty) {
1292
+ const ownerClass = toClass(owner);
1293
+ const ownerMeta = MetadataRegistry.get(ownerClass);
1294
+ const item = this.graph.items.find(
1295
+ (i) => i.relationProperty === relationProperty && (i.ownerClass === ownerClass || i.ownerClass.name === ownerClass.name)
1296
+ );
1297
+ if (item === void 0) {
1298
+ throw new Error(
1299
+ `Maintenance rebuild: no maintainer found for property '${relationProperty}' on '${ownerClass.name}'. The rebuilder repairs declared \`maintainedOn\` maintainers (snapshot / collection / counter); '${relationProperty}' declares none, or its model is not in this rebuilder's scope.`
1300
+ );
1301
+ }
1302
+ const ownerKey = (input) => {
1303
+ const { Key } = buildDeleteInput(
1304
+ ownerClass,
1305
+ input
1306
+ );
1307
+ return Key;
1308
+ };
1309
+ return { item, ownerMeta, ownerKey };
1310
+ }
1311
+ /**
1312
+ * Scan every source row bound to this owner row and re-derive the maintained value:
1313
+ *
1314
+ * - **collection** — project each source row, order + trim by the SAME read-side
1315
+ * rules (`orderBy` / `orderDir` / `maxItems`) the drain uses, return the list;
1316
+ * - **snapshot** — project the single bound source row (or `undefined` when none);
1317
+ * - **counter `count`** — the COUNT of source rows (absolute, not an ADD);
1318
+ * - **counter `max`** — the maximum source value of the tracked field.
1319
+ */
1320
+ async recompute(item, _ownerMeta, ownerKeyInput) {
1321
+ const effect = item.effect;
1322
+ if (effect.kind === "membership") {
1323
+ throw new Error(
1324
+ `Maintenance rebuild: sparse-view membership maintainer '${item.relationProperty}' on '${item.ownerEntity}' is not yet supported by the rebuild planner (#133 follow-up). A membership row appears/disappears per source row by predicate; rebuild it by re-running the source events through the drain.`
1325
+ );
1326
+ }
1327
+ const sources = await this.scanSourceRows(item, ownerKeyInput);
1328
+ if (effect.kind === "collection") {
1329
+ return this.recomputeCollection(effect, sources);
1330
+ }
1331
+ if (effect.kind === "snapshot") {
1332
+ return this.recomputeSnapshot(effect, sources);
1333
+ }
1334
+ return this.recomputeCounter(effect, sources);
1335
+ }
1336
+ /**
1337
+ * The source rows bound to one owner row. The maintenance key binding is
1338
+ * `{ ownerField: '$.entity.<sourceField>' }` (the destination owner-key field ← the
1339
+ * source row's field). INVERTED for a rebuild: the owner row supplies the value, and
1340
+ * the source partition is queried for rows whose `<sourceField>` matches — exactly the
1341
+ * set the live `hasMany` / `@aggregate` read aggregates. Queries the source via the
1342
+ * same {@link plan} the read planner uses (a partition Query), paginating fully.
1343
+ */
1344
+ async scanSourceRows(item, ownerKeyInput) {
1345
+ const sourceMeta = MetadataRegistry.get(item.sourceClass);
1346
+ const queryKey = {};
1347
+ for (const [ownerField, srcPath] of Object.entries(item.effect.keys)) {
1348
+ const sourceField = pathField(srcPath);
1349
+ queryKey[sourceField] = ownerKeyInput[ownerField];
1350
+ }
1351
+ const executor = ClientManager.getExecutor();
1352
+ const rows = [];
1353
+ let after;
1354
+ do {
1355
+ const execPlan = plan(sourceMeta, {
1356
+ key: queryKey,
1357
+ // Select all declared source fields so every projection / value source path
1358
+ // and the sort-key leaf is present on the scanned row.
1359
+ select: selectAll(sourceMeta),
1360
+ consistentRead: false,
1361
+ ...after ? { after } : {}
1362
+ });
1363
+ const op = execPlan.operations[0];
1364
+ const res = await executor.execute(op);
1365
+ for (const it of res.items) rows.push(it);
1366
+ after = res.lastEvaluatedKey;
1367
+ } while (after !== void 0);
1368
+ return rows;
1369
+ }
1370
+ /** Re-derive a bounded collection: project, then order + trim by the read-side rules. */
1371
+ recomputeCollection(effect, sources) {
1372
+ if (effect.collection.orderBy === void 0 && effect.collection.maxItems !== void 0) {
1373
+ throw new Error(
1374
+ `Maintenance rebuild: relation '${effect.collection.field}' maintains an UNORDERED bounded collection (\`maxItems: ${effect.collection.maxItems}\` with no \`read.order\`). Its trimmed contents depend on stream ARRIVAL order, which the source rows do not record, so the value cannot be faithfully rebuilt from source \u2014 a rebuild would keep a different subset than the drain converged to. Declare a \`read.order\` on the relation so the maintained collection orders by the source's sort-key field (then the rebuild and the drain agree), or rebuild it unbounded.`
1375
+ );
1376
+ }
1377
+ const projected = sources.map((row) => projectFrom(effect.project, row));
1378
+ return orderAndTrimCollection(projected, effect.collection);
1379
+ }
1380
+ /**
1381
+ * Re-derive a single-row snapshot. A snapshot mirrors ONE source row (a `belongsTo` /
1382
+ * `hasOne`, normally 1:1). In the unusual case where MORE THAN ONE source row binds, the
1383
+ * live single-value read takes the FIRST row in the source's natural query order
1384
+ * (`ScanIndexForward`, default ASC = the smallest sort-key leaf), so re-derive the same
1385
+ * row — {@link pickFirst} — for a verdict consistent with the live read. Returns
1386
+ * `undefined` when no source row is bound (the snapshot should be absent).
1387
+ */
1388
+ recomputeSnapshot(effect, sources) {
1389
+ if (sources.length === 0) return void 0;
1390
+ const chosen = sources.length === 1 ? sources[0] : pickFirst(sources, this.sourceMetaOf(effect));
1391
+ return projectFrom(effect.project, chosen);
1392
+ }
1393
+ /** The source entity metadata a maintain effect projects from (its relation target). */
1394
+ sourceMetaOf(effect) {
1395
+ const item = this.graph.items.find((i) => i.effect === effect);
1396
+ return item ? MetadataRegistry.get(item.sourceClass) : void 0;
1397
+ }
1398
+ /** Re-derive a counter: the absolute COUNT of source rows, or the MAX of the tracked field. */
1399
+ recomputeCounter(effect, sources) {
1400
+ if (effect.value.op === "count") {
1401
+ return sources.length;
1402
+ }
1403
+ const field2 = effect.value.field;
1404
+ let best;
1405
+ for (const row of sources) {
1406
+ const v = row[field2];
1407
+ if (v === void 0 || v === null) continue;
1408
+ if (best === void 0 || compareDesc(v, best) < 0) best = v;
1409
+ }
1410
+ return best;
1411
+ }
1412
+ /** Read the owner row (consistent) for the drift comparison / actual value. */
1413
+ async readOwnerRow(ownerMeta, ownerKey) {
1414
+ const res = await ClientManager.getExecutor().execute({
1415
+ type: "GetItem",
1416
+ tableName: TableMapping.resolve(ownerMeta.tableName),
1417
+ keyCondition: ownerKey,
1418
+ consistentRead: true
1419
+ });
1420
+ return res.items[0];
1421
+ }
1422
+ /** The value currently stored on the owner row for this maintainer. */
1423
+ actualValue(effect, ownerRow) {
1424
+ if (ownerRow === void 0) return void 0;
1425
+ if (effect.kind === "collection") {
1426
+ return ownerRow[effect.collection.field];
1427
+ }
1428
+ if (effect.kind === "counter") {
1429
+ return ownerRow[effect.attribute];
1430
+ }
1431
+ const out = {};
1432
+ let anyPresent = false;
1433
+ for (const attr of Object.keys(effect.project)) {
1434
+ if (Object.prototype.hasOwnProperty.call(ownerRow, attr)) {
1435
+ out[attr] = ownerRow[attr];
1436
+ anyPresent = true;
1437
+ }
1438
+ }
1439
+ return anyPresent ? out : void 0;
1440
+ }
1441
+ /** Write the re-derived ABSOLUTE value onto the owner row (the repair). */
1442
+ async writeValue(effect, ownerMeta, ownerKey, expected) {
1443
+ const TableName = TableMapping.resolve(ownerMeta.tableName);
1444
+ const executor = ClientManager.getExecutor();
1445
+ if (effect.kind === "collection") {
1446
+ await executor.update({
1447
+ TableName,
1448
+ Key: ownerKey,
1449
+ UpdateExpression: "SET #c = :list",
1450
+ ExpressionAttributeNames: { "#c": effect.collection.field },
1451
+ ExpressionAttributeValues: { ":list": expected ?? [] }
1452
+ });
1453
+ return;
1454
+ }
1455
+ if (effect.kind === "counter") {
1456
+ if (expected === void 0 || expected === null) {
1457
+ if (effect.value.op === "count") {
1458
+ await executor.update({
1459
+ TableName,
1460
+ Key: ownerKey,
1461
+ UpdateExpression: "SET #a = :v",
1462
+ ExpressionAttributeNames: { "#a": effect.attribute },
1463
+ ExpressionAttributeValues: { ":v": 0 }
1464
+ });
1465
+ } else {
1466
+ await executor.update({
1467
+ TableName,
1468
+ Key: ownerKey,
1469
+ UpdateExpression: "REMOVE #a",
1470
+ ExpressionAttributeNames: { "#a": effect.attribute }
1471
+ });
1472
+ }
1473
+ return;
1474
+ }
1475
+ await executor.update({
1476
+ TableName,
1477
+ Key: ownerKey,
1478
+ UpdateExpression: "SET #a = :v",
1479
+ ExpressionAttributeNames: { "#a": effect.attribute },
1480
+ ExpressionAttributeValues: { ":v": expected }
1481
+ });
1482
+ return;
1483
+ }
1484
+ if (expected === void 0) {
1485
+ const names2 = {};
1486
+ const removes = [];
1487
+ let i2 = 0;
1488
+ for (const attr of Object.keys(effect.project)) {
1489
+ names2[`#m${i2}`] = attr;
1490
+ removes.push(`#m${i2}`);
1491
+ i2 += 1;
1492
+ }
1493
+ if (removes.length === 0) return;
1494
+ await executor.update({
1495
+ TableName,
1496
+ Key: ownerKey,
1497
+ UpdateExpression: `REMOVE ${removes.join(", ")}`,
1498
+ ExpressionAttributeNames: names2
1499
+ });
1500
+ return;
1501
+ }
1502
+ const projection = expected;
1503
+ const names = {};
1504
+ const values = {};
1505
+ const sets = [];
1506
+ let i = 0;
1507
+ for (const [attr, value] of Object.entries(projection)) {
1508
+ names[`#m${i}`] = attr;
1509
+ values[`:m${i}`] = value;
1510
+ sets.push(`#m${i} = :m${i}`);
1511
+ i += 1;
1512
+ }
1513
+ if (sets.length === 0) return;
1514
+ await executor.update({
1515
+ TableName,
1516
+ Key: ownerKey,
1517
+ UpdateExpression: `SET ${sets.join(", ")}`,
1518
+ ExpressionAttributeNames: names,
1519
+ ExpressionAttributeValues: values
1520
+ });
1521
+ }
1522
+ };
1523
+ function selectAll(meta) {
1524
+ const select = {};
1525
+ for (const f of meta.fields) select[f.propertyName] = true;
1526
+ return select;
1527
+ }
1528
+ function pickFirst(sources, sourceMeta) {
1529
+ const skFields = sourceMeta?.primaryKey ? segmentFieldNames(sourceMeta.primaryKey.segmented.skSegments) : [];
1530
+ const orderField = skFields[skFields.length - 1];
1531
+ if (orderField === void 0) return sources[0];
1532
+ return [...sources].sort((a, b) => compareDesc(b[orderField], a[orderField]))[0];
1533
+ }
1534
+ function normalize(value) {
1535
+ if (value === void 0 || value === null) return void 0;
1536
+ if (typeof value === "number") return value;
1537
+ if (typeof value === "string" && /^-?\d+$/.test(value)) {
1538
+ const n = Number(value);
1539
+ if (Number.isSafeInteger(n)) return n;
1540
+ }
1541
+ if (Array.isArray(value)) return value.map(normalize);
1542
+ if (typeof value === "object") {
1543
+ const out = {};
1544
+ for (const [k2, v] of Object.entries(value)) {
1545
+ const nv = normalize(v);
1546
+ if (nv === void 0) continue;
1547
+ out[k2] = nv;
1548
+ }
1549
+ return out;
1550
+ }
1551
+ return value;
1552
+ }
1553
+ function isEmptyMaintained(effect, expected) {
1554
+ if (effect.kind === "collection") {
1555
+ return Array.isArray(expected) && expected.length === 0;
1556
+ }
1557
+ if (effect.kind === "counter") {
1558
+ return effect.value.op === "count" ? expected === 0 : expected === void 0 || expected === null;
1559
+ }
1560
+ return expected === void 0;
1561
+ }
1562
+ function deepEqual(a, b) {
1563
+ if (a === b) return true;
1564
+ if (a === void 0 || b === void 0 || a === null || b === null) return a === b;
1565
+ if (Array.isArray(a) && Array.isArray(b)) {
1566
+ if (a.length !== b.length) return false;
1567
+ return a.every((v, i) => deepEqual(v, b[i]));
1568
+ }
1569
+ if (typeof a === "object" && typeof b === "object") {
1570
+ const ka = Object.keys(a);
1571
+ const kb = Object.keys(b);
1572
+ if (ka.length !== kb.length) return false;
1573
+ return ka.every(
1574
+ (k2) => deepEqual(
1575
+ a[k2],
1576
+ b[k2]
1577
+ )
1578
+ );
1579
+ }
1580
+ return false;
1581
+ }
1582
+ function createMaintenanceRebuilder(opts = {}) {
1583
+ return new MaintenanceRebuilder(opts);
1584
+ }
1585
+
958
1586
  // src/define/define.ts
959
1587
  function collectParams(structure, out = {}) {
960
1588
  if (structure === null || typeof structure !== "object") return out;
@@ -1172,7 +1800,10 @@ export {
1172
1800
  ENTITY_WRITES_MARKER,
1173
1801
  LIFECYCLE_CONTRACT_MARKER,
1174
1802
  Linter,
1803
+ MAINT_OUTBOX_PK_PREFIX,
1175
1804
  MAX_TRANSACT_ITEMS,
1805
+ MaintenanceDrain,
1806
+ MaintenanceRebuilder,
1176
1807
  MetadataRegistry,
1177
1808
  OLD_VALUE_NAMESPACE,
1178
1809
  RetryingExecutor,
@@ -1206,6 +1837,7 @@ export {
1206
1837
  buildUpdateInput,
1207
1838
  collectContractBoundaryViolations,
1208
1839
  collectContractN1Violations,
1840
+ collectViewDefinitions,
1209
1841
  compileFilterExpression,
1210
1842
  compileFragment,
1211
1843
  compileMutationPlan,
@@ -1215,6 +1847,9 @@ export {
1215
1847
  count,
1216
1848
  createCdcEmulator,
1217
1849
  createDefaultLinter,
1850
+ createMaintenanceDrain,
1851
+ createMaintenanceDrainHandler,
1852
+ createMaintenanceRebuilder,
1218
1853
  datetime,
1219
1854
  decodeCursor,
1220
1855
  decodePerKeyCursor,
@@ -1294,6 +1929,7 @@ export {
1294
1929
  list,
1295
1930
  literal,
1296
1931
  maintainTrigger,
1932
+ maintainedFrom,
1297
1933
  map,
1298
1934
  max,
1299
1935
  mintContractKeyFieldRef,
@@ -1323,5 +1959,6 @@ export {
1323
1959
  validateDepth,
1324
1960
  validateGsiAmbiguity,
1325
1961
  when,
1962
+ whenMember,
1326
1963
  wholeKeysSentinel
1327
1964
  };