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.
@@ -1,7 +1,13 @@
1
1
  import {
2
2
  ChangeCaptureRegistry,
3
+ ClientManager,
4
+ MetadataRegistry,
5
+ buildConditionExpression,
6
+ buildDeleteInput,
7
+ buildMaintenanceGraph,
8
+ buildPutInput,
3
9
  resolveModelClass
4
- } from "./chunk-6AIAHP3A.js";
10
+ } from "./chunk-72VZYOSG.js";
5
11
 
6
12
  // src/cdc/prng.ts
7
13
  var SeededRandom = class {
@@ -455,7 +461,346 @@ function createCdcEmulator(opts = {}) {
455
461
  return new CdcEmulator(opts);
456
462
  }
457
463
 
464
+ // src/relation/maintenance-projection.ts
465
+ var PATH_FIELD_RE = /^\$\.(?:entity|input)\.([A-Za-z_$][\w$]*)$/;
466
+ function pathField(path) {
467
+ const m = PATH_FIELD_RE.exec(path);
468
+ if (m !== null) return m[1];
469
+ return path;
470
+ }
471
+ function applyTransform(op, args, value) {
472
+ if (op === "identity") return value;
473
+ if (op === "preview") {
474
+ const n = args[0];
475
+ if (typeof n !== "number" || !Number.isInteger(n) || n <= 0) {
476
+ throw new Error(
477
+ `maintenance projection: a 'preview' projection has a non-positive-integer length bound (${JSON.stringify(n)}).`
478
+ );
479
+ }
480
+ if (value === null || value === void 0) return value;
481
+ return String(value).slice(0, n);
482
+ }
483
+ throw new Error(`maintenance projection: unknown projection transform op '${op}'.`);
484
+ }
485
+ function projectFrom(project, source) {
486
+ const out = {};
487
+ for (const [attr, transform] of Object.entries(project)) {
488
+ const field = pathField(transform.path);
489
+ out[attr] = applyTransform(transform.op, transform.args, source[field]);
490
+ }
491
+ return out;
492
+ }
493
+ function compareDesc(a, b) {
494
+ if (a === b) return 0;
495
+ if (a === void 0 || a === null) return 1;
496
+ if (b === void 0 || b === null) return -1;
497
+ return a > b ? -1 : 1;
498
+ }
499
+ function orderAndTrimCollection(items, collection) {
500
+ let next = [...items];
501
+ const orderBy = collection.orderBy;
502
+ if (orderBy !== void 0) {
503
+ const orderField = pathField(orderBy);
504
+ const dir = collection.orderDir ?? "DESC";
505
+ next.sort(
506
+ (a, b) => dir === "ASC" ? compareDesc(b[orderField], a[orderField]) : compareDesc(a[orderField], b[orderField])
507
+ );
508
+ if (collection.maxItems !== void 0 && next.length > collection.maxItems) {
509
+ next = next.slice(0, collection.maxItems);
510
+ }
511
+ } else if (collection.maxItems !== void 0 && next.length > collection.maxItems) {
512
+ next = next.slice(next.length - collection.maxItems);
513
+ }
514
+ return next;
515
+ }
516
+ function collectionIdentityAttr(effect) {
517
+ return Object.keys(effect.project)[0];
518
+ }
519
+
520
+ // src/cdc/maintenance-drain.ts
521
+ var MAINT_OUTBOX_PK_PREFIX = "OUTBOX#MAINT#";
522
+ var OWNER_ATTR = "__maintOwner__";
523
+ var RELATION_ATTR = "__maintRelation__";
524
+ var TRIGGER_ATTR = "__maintTrigger__";
525
+ function ownerKey(ownerClass, effect, source) {
526
+ const keyInput = {};
527
+ for (const [ownerField, srcPath] of Object.entries(effect.keys)) {
528
+ keyInput[ownerField] = source[pathField(srcPath)];
529
+ }
530
+ const { TableName, Key } = buildDeleteInput(
531
+ ownerClass,
532
+ keyInput
533
+ );
534
+ return { TableName, Key };
535
+ }
536
+ var MaintenanceDrain = class {
537
+ graph;
538
+ /** Owner entity name → its model class (for the owner-row key derivation). */
539
+ ownerByName = /* @__PURE__ */ new Map();
540
+ /** Per-event-id de-dup within a single handler invocation (at-least-once). */
541
+ appliedSeq = /* @__PURE__ */ new Set();
542
+ /** Count of owner-row writes applied (test/diagnostic). */
543
+ applied = 0;
544
+ constructor(opts = {}) {
545
+ const classes = (opts.models ?? []).map((m) => {
546
+ try {
547
+ return resolveModelClass(m);
548
+ } catch {
549
+ return m;
550
+ }
551
+ });
552
+ const explicitViews = opts.views;
553
+ const allClasses = [
554
+ ...classes,
555
+ ...(explicitViews ?? []).map((v) => v.viewClass)
556
+ ];
557
+ const scoped = allClasses.length > 0 ? new Map(allClasses.map((c) => [c, MetadataRegistry.get(c)])) : void 0;
558
+ this.graph = buildMaintenanceGraph(scoped, explicitViews);
559
+ for (const item of this.graph.items) {
560
+ this.ownerByName.set(item.ownerEntity, item.ownerClass);
561
+ }
562
+ }
563
+ /** The CDC {@link ChangeHandler} to subscribe to an emulator / Streams source. */
564
+ handler = async (batch) => {
565
+ const failures = [];
566
+ for (const event of batch.records) {
567
+ if (!event.keys.pk.startsWith(MAINT_OUTBOX_PK_PREFIX)) continue;
568
+ if (event.eventName === "REMOVE") continue;
569
+ const image = event.newImage;
570
+ if (image === void 0) continue;
571
+ try {
572
+ await this.applyOne(event, image);
573
+ } catch (e) {
574
+ failures.push(event.sequenceNumber);
575
+ if (process.env.GRAPHDDB_DRAIN_DEBUG) {
576
+ console.error("DRAIN ERROR", event.keys.pk, e);
577
+ }
578
+ }
579
+ }
580
+ return failures.length > 0 ? { batchItemFailures: failures } : {};
581
+ };
582
+ /** Resolve one maintenance-outbox event to its effect and apply the owner-row write. */
583
+ async applyOne(event, image) {
584
+ const eventId = `${event.shardId}#${event.sequenceNumber}`;
585
+ if (this.appliedSeq.has(eventId)) return;
586
+ const ownerEntity = String(image[OWNER_ATTR] ?? "");
587
+ const relationProperty = String(image[RELATION_ATTR] ?? "");
588
+ const trigger = String(image[TRIGGER_ATTR] ?? "");
589
+ const item = this.resolve(trigger, ownerEntity, relationProperty);
590
+ if (item === void 0) {
591
+ this.appliedSeq.add(eventId);
592
+ return;
593
+ }
594
+ const effect = item.effect;
595
+ const ownerClass = item.ownerClass;
596
+ const { TableName, Key } = ownerKey(ownerClass, effect, image);
597
+ const executor = ClientManager.getExecutor();
598
+ if (effect.kind === "counter") {
599
+ if (effect.value.op === "count") {
600
+ await executor.update({
601
+ TableName,
602
+ Key,
603
+ UpdateExpression: "ADD #a0 :a0",
604
+ ExpressionAttributeNames: { "#a0": effect.attribute },
605
+ ExpressionAttributeValues: { ":a0": effect.delta ?? 0 }
606
+ });
607
+ } else {
608
+ await this.applyMax(TableName, Key, effect.attribute, effect.value.field, image);
609
+ }
610
+ } else if (effect.kind === "membership") {
611
+ await this.applyMembership(TableName, Key, item, effect, image);
612
+ } else if (effect.kind === "snapshot") {
613
+ const projection = projectFrom(effect.project, image);
614
+ await this.applySnapshot(TableName, Key, projection);
615
+ } else {
616
+ await this.applyCollection(TableName, Key, item, image);
617
+ }
618
+ this.appliedSeq.add(eventId);
619
+ this.applied += 1;
620
+ }
621
+ /** Resolve a maintenance-outbox event back to the declared {@link MaintainItem}. */
622
+ resolve(trigger, ownerEntity, relationProperty) {
623
+ for (const item of this.graph.effectsFor(trigger)) {
624
+ const itemOwnerName = item.ownerClass.name;
625
+ if ((itemOwnerName === ownerEntity || item.ownerEntity === ownerEntity) && item.relationProperty === relationProperty) {
626
+ return item;
627
+ }
628
+ }
629
+ return void 0;
630
+ }
631
+ /** A `snapshot` SET of each projected attribute onto the owner row (idempotent). */
632
+ async applySnapshot(TableName, Key, projection) {
633
+ const names = {};
634
+ const values = {};
635
+ const sets = [];
636
+ let i = 0;
637
+ for (const [attr, value] of Object.entries(projection)) {
638
+ names[`#m${i}`] = attr;
639
+ values[`:m${i}`] = value;
640
+ sets.push(`#m${i} = :m${i}`);
641
+ i += 1;
642
+ }
643
+ if (sets.length === 0) return;
644
+ await ClientManager.getExecutor().update({
645
+ TableName,
646
+ Key,
647
+ UpdateExpression: `SET ${sets.join(", ")}`,
648
+ ExpressionAttributeNames: names,
649
+ ExpressionAttributeValues: values
650
+ });
651
+ }
652
+ /**
653
+ * A sparse-view **membership** write (#133): evaluate the membership predicate against
654
+ * the source image; PUT the view row (its projection + key fields) when the predicate
655
+ * holds, DELETE it when it flips false. A source `removed` event always deletes (the
656
+ * source no longer exists, so its view row must disappear regardless of the predicate).
657
+ *
658
+ * Both ops are idempotent under at-least-once redelivery: a repeated PUT writes the same
659
+ * row, a repeated DELETE is a no-op. The view row is keyed by the source identity
660
+ * (`effect.keys`), so the PUT/DELETE always targets the SAME physical row the predicate
661
+ * gates.
662
+ */
663
+ async applyMembership(TableName, Key, item, effect, image) {
664
+ void TableName;
665
+ const ownerClass = item.ownerClass;
666
+ const isRemoved = item.trigger.endsWith(".removed");
667
+ const holds = !isRemoved && evaluateMembership(effect.predicate, image);
668
+ if (holds) {
669
+ const keyInput2 = {};
670
+ for (const [ownerField, srcPath] of Object.entries(effect.keys)) {
671
+ keyInput2[ownerField] = image[pathField(srcPath)];
672
+ }
673
+ const projection = projectFrom(effect.project, image);
674
+ const putItem = { ...keyInput2, ...projection };
675
+ const putInput = buildPutInput(ownerClass, putItem);
676
+ await ClientManager.getExecutor().put(putInput);
677
+ return;
678
+ }
679
+ const keyInput = {};
680
+ for (const [ownerField, srcPath] of Object.entries(effect.keys)) {
681
+ keyInput[ownerField] = image[pathField(srcPath)];
682
+ }
683
+ const deleteInput = buildDeleteInput(ownerClass, keyInput);
684
+ await ClientManager.getExecutor().delete(deleteInput);
685
+ }
686
+ /**
687
+ * A running `max`: read the owner row, and `SET` the attribute to the source value
688
+ * only when it is greater (or absent). Implemented as a conditional update so a
689
+ * redelivered / out-of-order older value never regresses the stored max — idempotent
690
+ * and commutative under at-least-once delivery.
691
+ */
692
+ async applyMax(TableName, Key, attribute, sourceField, image) {
693
+ const candidate = image[sourceField];
694
+ if (candidate === void 0 || candidate === null) return;
695
+ const cond = buildConditionExpression({ attributeNotExists: attribute });
696
+ try {
697
+ await ClientManager.getExecutor().update({
698
+ TableName,
699
+ Key,
700
+ UpdateExpression: "SET #a0 = :v",
701
+ ConditionExpression: `attribute_not_exists(#a0) OR #a0 < :v`,
702
+ ExpressionAttributeNames: { "#a0": attribute },
703
+ ExpressionAttributeValues: { ":v": candidate }
704
+ });
705
+ } catch (e) {
706
+ void cond;
707
+ if (isConditionFailure(e)) return;
708
+ throw e;
709
+ }
710
+ }
711
+ /**
712
+ * A bounded `collection`: read the current list, apply the event (append for a
713
+ * `created`/`updated` source, splice for a `removed`), de-duplicate by the projected
714
+ * identity key, order by `orderBy` (in `orderDir`, default DESC), trim to `maxItems`, and write the whole
715
+ * list back. The read-modify-write is exactly what a single synchronous
716
+ * `UpdateExpression` cannot do — the reason a bounded/ordered collection is a stream
717
+ * maintainer (Phase 1 sync was append-only).
718
+ */
719
+ async applyCollection(TableName, Key, item, image) {
720
+ const effect = item.effect;
721
+ if (effect.kind !== "collection") return;
722
+ const field = effect.collection.field;
723
+ const executor = ClientManager.getExecutor();
724
+ const res = await executor.execute({
725
+ type: "GetItem",
726
+ tableName: TableName,
727
+ keyCondition: Key,
728
+ consistentRead: true
729
+ });
730
+ const current = res.items[0];
731
+ const existing = Array.isArray(current?.[field]) ? [...current[field]] : [];
732
+ const projected = projectFrom(effect.project, image);
733
+ const idAttr = collectionIdentityAttr(effect);
734
+ const idOf = (row) => idAttr ? row[idAttr] : void 0;
735
+ const isRemoved = item.trigger.endsWith(".removed");
736
+ let next;
737
+ if (isRemoved) {
738
+ next = existing.filter((row) => idOf(row) !== idOf(projected));
739
+ } else {
740
+ next = existing.filter((row) => idOf(row) !== idOf(projected));
741
+ next.push(projected);
742
+ }
743
+ next = orderAndTrimCollection(next, effect.collection);
744
+ await executor.update({
745
+ TableName,
746
+ Key,
747
+ UpdateExpression: "SET #c = :list",
748
+ ExpressionAttributeNames: { "#c": field },
749
+ ExpressionAttributeValues: { ":list": next }
750
+ });
751
+ }
752
+ /** Owner-row writes applied so far (test/diagnostic). */
753
+ appliedCount() {
754
+ return this.applied;
755
+ }
756
+ /** Clear the per-delivery de-dup set + counters (test reset). */
757
+ reset() {
758
+ this.appliedSeq = /* @__PURE__ */ new Set();
759
+ this.applied = 0;
760
+ }
761
+ };
762
+ function evaluateMembership(predicate, image) {
763
+ const field = pathField(predicate.path);
764
+ const present = Object.prototype.hasOwnProperty.call(image, field);
765
+ const value = image[field];
766
+ switch (predicate.op) {
767
+ case "exists":
768
+ return present && value !== null && value !== void 0;
769
+ case "notExists":
770
+ return !present || value === null || value === void 0;
771
+ case "truthy":
772
+ return Boolean(value);
773
+ case "falsy":
774
+ return !value;
775
+ case "eq":
776
+ return value === predicate.value;
777
+ case "ne":
778
+ return value !== predicate.value;
779
+ default:
780
+ return false;
781
+ }
782
+ }
783
+ function isConditionFailure(e) {
784
+ const name = e?.name ?? "";
785
+ const msg = e instanceof Error ? e.message : String(e);
786
+ return name === "ConditionalCheckFailedException" || /ConditionalCheckFailed|condition/i.test(msg);
787
+ }
788
+ function createMaintenanceDrain(opts = {}) {
789
+ return new MaintenanceDrain(opts);
790
+ }
791
+ function createMaintenanceDrainHandler(opts = {}) {
792
+ return createMaintenanceDrain(opts).handler;
793
+ }
794
+
458
795
  export {
459
796
  CdcEmulator,
460
- createCdcEmulator
797
+ createCdcEmulator,
798
+ pathField,
799
+ projectFrom,
800
+ compareDesc,
801
+ orderAndTrimCollection,
802
+ MAINT_OUTBOX_PK_PREFIX,
803
+ MaintenanceDrain,
804
+ createMaintenanceDrain,
805
+ createMaintenanceDrainHandler
461
806
  };