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/README.md +1 -0
- package/dist/{chunk-6AIAHP3A.js → chunk-72VZYOSG.js} +850 -1
- package/dist/{chunk-QQNP43JL.js → chunk-GWFZVNAV.js} +347 -2
- package/dist/{chunk-QEOFIXTN.js → chunk-PC54CW5P.js} +231 -554
- package/dist/cli.js +2 -2
- package/dist/index.d.ts +673 -5
- package/dist/index.js +658 -21
- package/dist/testing/index.d.ts +1 -1
- package/dist/testing/index.js +2 -2
- package/dist/{types-xJRn5qkv.d.ts → types-CpCo8yHP.d.ts} +398 -66
- package/package.json +1 -1
|
@@ -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-
|
|
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
|
};
|