graphddb 0.2.4 → 0.3.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 +322 -305
- package/dist/{chunk-GCPEUPAA.js → chunk-6AIAHP3A.js} +1129 -156
- package/dist/{chunk-IQEOJVHI.js → chunk-QEOFIXTN.js} +1194 -60
- package/dist/{chunk-LNESVUTO.js → chunk-QQNP43JL.js} +1 -1
- package/dist/cli.js +2 -2
- package/dist/index.d.ts +275 -73
- package/dist/index.js +131 -7
- package/dist/testing/index.d.ts +1 -1
- package/dist/testing/index.js +2 -2
- package/dist/{types-BEeQ_nSd.d.ts → types-xJRn5qkv.d.ts} +1567 -237
- package/package.json +1 -1
|
@@ -411,6 +411,437 @@ var relationDepthRule = {
|
|
|
411
411
|
}
|
|
412
412
|
};
|
|
413
413
|
|
|
414
|
+
// src/linter/rules/same-partition-preset.ts
|
|
415
|
+
var samePartitionPresetRule = {
|
|
416
|
+
id: "same-partition-preset",
|
|
417
|
+
severity: "error",
|
|
418
|
+
check(metadata, registry) {
|
|
419
|
+
const results = [];
|
|
420
|
+
for (const relation of metadata.relations) {
|
|
421
|
+
if (relation.options?.pattern !== "samePartition") continue;
|
|
422
|
+
results.push(...checkRelation(metadata, relation, registry));
|
|
423
|
+
}
|
|
424
|
+
return results;
|
|
425
|
+
}
|
|
426
|
+
};
|
|
427
|
+
function err(metadata, relation, message) {
|
|
428
|
+
return {
|
|
429
|
+
ruleId: "same-partition-preset",
|
|
430
|
+
severity: "error",
|
|
431
|
+
message: `Relation '${relation.propertyName}' on entity '${metadata.prefix}' declares pattern 'samePartition' but ${message}`,
|
|
432
|
+
entity: metadata.prefix,
|
|
433
|
+
field: relation.propertyName
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
function checkRelation(metadata, relation, registry) {
|
|
437
|
+
if (relation.type !== "hasMany") {
|
|
438
|
+
return [
|
|
439
|
+
err(
|
|
440
|
+
metadata,
|
|
441
|
+
relation,
|
|
442
|
+
`is a '${relation.type}'. 'samePartition' lowers a parent\u2192children collection read (begins_with on a segmented sort key) and is only valid on @hasMany.`
|
|
443
|
+
)
|
|
444
|
+
];
|
|
445
|
+
}
|
|
446
|
+
const results = [];
|
|
447
|
+
const maintenanceOptions = ["read", "write", "projection"].filter(
|
|
448
|
+
(k2) => relation.options?.[k2] !== void 0
|
|
449
|
+
);
|
|
450
|
+
if (maintenanceOptions.length > 0) {
|
|
451
|
+
results.push(
|
|
452
|
+
err(
|
|
453
|
+
metadata,
|
|
454
|
+
relation,
|
|
455
|
+
`also sets [${maintenanceOptions.join(", ")}]. 'samePartition' has no maintained materialisation \u2014 children are read live by begins_with, so it carries no read/write/projection effect. Drop these options (or use a maintained preset such as 'embeddedSnapshot').`
|
|
456
|
+
)
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
const targetFields = Object.keys(relation.keyBinding);
|
|
460
|
+
if (targetFields.length === 0) {
|
|
461
|
+
results.push(
|
|
462
|
+
err(metadata, relation, `has an empty keyBinding; nothing to lower.`)
|
|
463
|
+
);
|
|
464
|
+
return results;
|
|
465
|
+
}
|
|
466
|
+
let targetMeta;
|
|
467
|
+
try {
|
|
468
|
+
const targetClass = relation.targetFactory();
|
|
469
|
+
targetMeta = registry.get(targetClass);
|
|
470
|
+
} catch {
|
|
471
|
+
results.push(
|
|
472
|
+
err(
|
|
473
|
+
metadata,
|
|
474
|
+
relation,
|
|
475
|
+
`targets an entity that is not registered in MetadataRegistry.`
|
|
476
|
+
)
|
|
477
|
+
);
|
|
478
|
+
return results;
|
|
479
|
+
}
|
|
480
|
+
let resolved;
|
|
481
|
+
try {
|
|
482
|
+
resolved = resolveKey(targetFields, targetMeta);
|
|
483
|
+
} catch {
|
|
484
|
+
results.push(
|
|
485
|
+
err(
|
|
486
|
+
metadata,
|
|
487
|
+
relation,
|
|
488
|
+
`binds [${targetFields.join(", ")}] on target '${targetMeta.prefix}', which matches no access pattern. A same-partition relation must bind a contiguous prefix of the target's primary key.`
|
|
489
|
+
)
|
|
490
|
+
);
|
|
491
|
+
return results;
|
|
492
|
+
}
|
|
493
|
+
if (resolved.type !== "pk") {
|
|
494
|
+
results.push(
|
|
495
|
+
err(
|
|
496
|
+
metadata,
|
|
497
|
+
relation,
|
|
498
|
+
`binds [${targetFields.join(", ")}] to a ${resolved.type.toUpperCase()} on target '${targetMeta.prefix}'. A GSI is a different partition; 'samePartition' requires the binding to resolve on the target's primary key (same PK, segmented-SK begins_with).`
|
|
499
|
+
)
|
|
500
|
+
);
|
|
501
|
+
return results;
|
|
502
|
+
}
|
|
503
|
+
if (!resolved.partial) {
|
|
504
|
+
results.push(
|
|
505
|
+
err(
|
|
506
|
+
metadata,
|
|
507
|
+
relation,
|
|
508
|
+
`binds the target's full primary key on '${targetMeta.prefix}', which is a point read of a single item, not a child collection. 'samePartition' requires a partial key \u2014 the parent fields fully supply the PK and a proper prefix of the segmented SK, leaving a begins_with boundary for the children.`
|
|
509
|
+
)
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
return results;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// src/linter/rules/maintenance-shared.ts
|
|
516
|
+
function isMaintainedRelation(relation) {
|
|
517
|
+
const triggers = relation.options?.write?.maintainedOn;
|
|
518
|
+
return Array.isArray(triggers) && triggers.length > 0;
|
|
519
|
+
}
|
|
520
|
+
function maintainedRelations(metadata) {
|
|
521
|
+
return metadata.relations.filter(isMaintainedRelation);
|
|
522
|
+
}
|
|
523
|
+
function logicalName(metadata) {
|
|
524
|
+
return metadata.prefix.endsWith("#") ? metadata.prefix.slice(0, -1) : metadata.prefix;
|
|
525
|
+
}
|
|
526
|
+
function payloadAttributes(meta) {
|
|
527
|
+
const attrs = /* @__PURE__ */ new Set();
|
|
528
|
+
for (const f of meta.fields) attrs.add(f.propertyName);
|
|
529
|
+
if (meta.primaryKey) {
|
|
530
|
+
for (const f of segmentFieldNames(meta.primaryKey.segmented.pkSegments)) {
|
|
531
|
+
attrs.add(f);
|
|
532
|
+
}
|
|
533
|
+
for (const f of segmentFieldNames(meta.primaryKey.segmented.skSegments)) {
|
|
534
|
+
attrs.add(f);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
for (const gsi2 of meta.gsiDefinitions) {
|
|
538
|
+
for (const f of segmentFieldNames(gsi2.segmented.pkSegments)) attrs.add(f);
|
|
539
|
+
for (const f of segmentFieldNames(gsi2.segmented.skSegments)) attrs.add(f);
|
|
540
|
+
}
|
|
541
|
+
return attrs;
|
|
542
|
+
}
|
|
543
|
+
function isPayloadRooted(value) {
|
|
544
|
+
return value.startsWith("$.input.") || value.startsWith("$.entity.");
|
|
545
|
+
}
|
|
546
|
+
function projectionSourceAttribute(value) {
|
|
547
|
+
const path = typeof value === "string" ? value : value.path;
|
|
548
|
+
if (typeof value === "string") {
|
|
549
|
+
if (!isPayloadRooted(path)) return path;
|
|
550
|
+
} else if (!isPayloadRooted(path)) {
|
|
551
|
+
return null;
|
|
552
|
+
}
|
|
553
|
+
const stripped = path.replace(/^\$\.(input|entity)\./, "");
|
|
554
|
+
return stripped.split(".")[0] ?? stripped;
|
|
555
|
+
}
|
|
556
|
+
function projectionEntries(projection) {
|
|
557
|
+
return projection ? Object.entries(projection) : [];
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// src/linter/rules/missing-context.ts
|
|
561
|
+
var missingContextRule = {
|
|
562
|
+
id: "missing-context",
|
|
563
|
+
severity: "error",
|
|
564
|
+
check(metadata, registry) {
|
|
565
|
+
const results = [];
|
|
566
|
+
for (const relation of maintainedRelations(metadata)) {
|
|
567
|
+
results.push(...checkRelation2(metadata, relation, registry));
|
|
568
|
+
}
|
|
569
|
+
return results;
|
|
570
|
+
}
|
|
571
|
+
};
|
|
572
|
+
function err2(metadata, relation, message) {
|
|
573
|
+
return {
|
|
574
|
+
ruleId: "missing-context",
|
|
575
|
+
severity: "error",
|
|
576
|
+
message: `Maintained relation '${relation.propertyName}' on '${logicalName(metadata)}' ${message}`,
|
|
577
|
+
entity: metadata.prefix,
|
|
578
|
+
field: relation.propertyName
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
function checkRelation2(metadata, relation, registry) {
|
|
582
|
+
let sourceMeta;
|
|
583
|
+
try {
|
|
584
|
+
sourceMeta = registry.get(relation.targetFactory());
|
|
585
|
+
} catch {
|
|
586
|
+
return [];
|
|
587
|
+
}
|
|
588
|
+
const sourceName = logicalName(sourceMeta);
|
|
589
|
+
const payload = payloadAttributes(sourceMeta);
|
|
590
|
+
const available = `available: ${[...payload].sort().map((f) => `'${f}'`).join(", ")}`;
|
|
591
|
+
const results = [];
|
|
592
|
+
for (const sourceField of Object.keys(relation.keyBinding)) {
|
|
593
|
+
if (!payload.has(sourceField)) {
|
|
594
|
+
results.push(
|
|
595
|
+
err2(
|
|
596
|
+
metadata,
|
|
597
|
+
relation,
|
|
598
|
+
`resolves its destination row from source field '${sourceField}', but the source entity '${sourceName}' carries no such attribute on its write payload (${available}). Phase 1 maintenance is payload \u540C\u68B1 only (no write-time fetch) \u2014 include '${sourceField}' on '${sourceName}' or bind a key field it does carry.`
|
|
599
|
+
)
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
for (const [attr, value] of projectionEntries(relation.options?.projection)) {
|
|
604
|
+
const path = typeof value === "string" ? value : value.path;
|
|
605
|
+
const srcAttr = projectionSourceAttribute(value);
|
|
606
|
+
if (srcAttr === null || typeof value !== "string" && !isPayloadRooted(path)) {
|
|
607
|
+
results.push(
|
|
608
|
+
err2(
|
|
609
|
+
metadata,
|
|
610
|
+
relation,
|
|
611
|
+
`projects '${attr}' from '${path}', which is not a payload-rooted source path (\`$.input.*\` / \`$.entity.*\`). Phase 1 maintenance projects only from the source payload (payload \u540C\u68B1); there is no fetch / re-projection.`
|
|
612
|
+
)
|
|
613
|
+
);
|
|
614
|
+
continue;
|
|
615
|
+
}
|
|
616
|
+
if (!payload.has(srcAttr)) {
|
|
617
|
+
results.push(
|
|
618
|
+
err2(
|
|
619
|
+
metadata,
|
|
620
|
+
relation,
|
|
621
|
+
`projects '${attr}' from source attribute '${srcAttr}', but the source entity '${sourceName}' carries no such attribute on its write payload (${available}). Phase 1 maintenance is payload \u540C\u68B1 only (no write-time fetch / re-projection) \u2014 an attribute the source row does not carry cannot be projected.`
|
|
622
|
+
)
|
|
623
|
+
);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
return results;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// src/linter/rules/embedded-snapshot-size.ts
|
|
630
|
+
var ITEM_LIMIT_BYTES = 400 * 1024;
|
|
631
|
+
var SOFT_BUDGET_BYTES = ITEM_LIMIT_BYTES / 2;
|
|
632
|
+
var DEFAULT_STRING_BYTES = 256;
|
|
633
|
+
var ATTR_NAME_OVERHEAD_BYTES = 16;
|
|
634
|
+
var embeddedSnapshotSizeRule = {
|
|
635
|
+
id: "400kb",
|
|
636
|
+
severity: "warning",
|
|
637
|
+
check(metadata, _registry) {
|
|
638
|
+
const results = [];
|
|
639
|
+
for (const relation of maintainedRelations(metadata)) {
|
|
640
|
+
const result = checkRelation3(metadata, relation);
|
|
641
|
+
if (result) results.push(result);
|
|
642
|
+
}
|
|
643
|
+
return results;
|
|
644
|
+
}
|
|
645
|
+
};
|
|
646
|
+
function estimateItemBytes(relation) {
|
|
647
|
+
let bytes = 0;
|
|
648
|
+
for (const [attr, value] of projectionEntries(relation.options?.projection)) {
|
|
649
|
+
bytes += ATTR_NAME_OVERHEAD_BYTES + attr.length;
|
|
650
|
+
bytes += attributeValueBytes(value);
|
|
651
|
+
}
|
|
652
|
+
return bytes;
|
|
653
|
+
}
|
|
654
|
+
function attributeValueBytes(value) {
|
|
655
|
+
if (typeof value !== "string" && value.op === "preview") {
|
|
656
|
+
const [n] = value.args;
|
|
657
|
+
if (typeof n === "number" && Number.isFinite(n)) return n;
|
|
658
|
+
}
|
|
659
|
+
return DEFAULT_STRING_BYTES;
|
|
660
|
+
}
|
|
661
|
+
function checkRelation3(metadata, relation) {
|
|
662
|
+
const itemBytes = estimateItemBytes(relation);
|
|
663
|
+
if (itemBytes === 0) return null;
|
|
664
|
+
const isCollection = relation.type === "hasMany";
|
|
665
|
+
const maxItems = relation.options?.read?.maxItems;
|
|
666
|
+
if (isCollection && maxItems === void 0) return null;
|
|
667
|
+
const count = isCollection ? maxItems : 1;
|
|
668
|
+
const estimate = itemBytes * count;
|
|
669
|
+
const shape = isCollection ? `${count} item(s) \xD7 ~${itemBytes} B` : `~${itemBytes} B snapshot`;
|
|
670
|
+
if (estimate >= ITEM_LIMIT_BYTES) {
|
|
671
|
+
return {
|
|
672
|
+
ruleId: "400kb",
|
|
673
|
+
severity: "error",
|
|
674
|
+
message: `Maintained relation '${relation.propertyName}' on '${logicalName(metadata)}' materializes ${shape} \u2248 ${estimate} B onto the owner row, which meets or exceeds DynamoDB's 400 KB item limit (${ITEM_LIMIT_BYTES} B). The maintenance write would fail once the collection fills \u2014 reduce \`read.maxItems\` or the projected attributes (e.g. tighter \`preview(n)\` bounds).`,
|
|
675
|
+
entity: metadata.prefix,
|
|
676
|
+
field: relation.propertyName
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
if (estimate >= SOFT_BUDGET_BYTES) {
|
|
680
|
+
return {
|
|
681
|
+
ruleId: "400kb",
|
|
682
|
+
severity: "warning",
|
|
683
|
+
message: `Maintained relation '${relation.propertyName}' on '${logicalName(metadata)}' materializes ${shape} \u2248 ${estimate} B onto the owner row, over half of DynamoDB's 400 KB item limit (${ITEM_LIMIT_BYTES} B). Little headroom remains for the rest of the item \u2014 consider a tighter \`read.maxItems\` / projection.`,
|
|
684
|
+
entity: metadata.prefix,
|
|
685
|
+
field: relation.propertyName
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
return null;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// src/linter/rules/hot-partition.ts
|
|
692
|
+
var HOT_TRIGGER_FANIN = 2;
|
|
693
|
+
var hotPartitionRule = {
|
|
694
|
+
id: "hot-partition",
|
|
695
|
+
severity: "warning",
|
|
696
|
+
check(metadata, _registry) {
|
|
697
|
+
const results = [];
|
|
698
|
+
const maintained = maintainedRelations(metadata);
|
|
699
|
+
for (const relation of maintained) {
|
|
700
|
+
if (relation.type !== "hasMany") continue;
|
|
701
|
+
if (relation.options?.read?.maxItems === void 0) {
|
|
702
|
+
results.push(unboundedWarning(metadata, relation));
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
const byTrigger = /* @__PURE__ */ new Map();
|
|
706
|
+
for (const relation of maintained) {
|
|
707
|
+
for (const trigger of relation.options?.write?.maintainedOn ?? []) {
|
|
708
|
+
const bucket = byTrigger.get(trigger);
|
|
709
|
+
if (bucket) bucket.push(relation.propertyName);
|
|
710
|
+
else byTrigger.set(trigger, [relation.propertyName]);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
for (const [trigger, props] of byTrigger) {
|
|
714
|
+
if (props.length > HOT_TRIGGER_FANIN) {
|
|
715
|
+
results.push(fanInWarning(metadata, trigger, props));
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
return results;
|
|
719
|
+
}
|
|
720
|
+
};
|
|
721
|
+
function unboundedWarning(metadata, relation) {
|
|
722
|
+
return {
|
|
723
|
+
ruleId: "hot-partition",
|
|
724
|
+
severity: "warning",
|
|
725
|
+
message: `Maintained collection '${relation.propertyName}' on '${logicalName(metadata)}' has no \`read.maxItems\` bound: every maintained source row appends to the same owner ("root") item, so that single partition takes unbounded write traffic and the item trends toward the 400 KB limit. Declare a \`read.maxItems\` window (e.g. latest-N) or read the children from their own partition (\`samePartition\`) instead of inlining a snapshot.`,
|
|
726
|
+
entity: metadata.prefix,
|
|
727
|
+
field: relation.propertyName
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
function fanInWarning(metadata, trigger, props) {
|
|
731
|
+
return {
|
|
732
|
+
ruleId: "hot-partition",
|
|
733
|
+
severity: "warning",
|
|
734
|
+
message: `${props.length} maintained relations on '${logicalName(metadata)}' ([${props.map((p) => `'${p}'`).join(", ")}]) all fire on trigger '${trigger}', concentrating that many maintenance writes onto the single owner ("root") row in one mutation. This amplifies per-source-row write cost on one partition \u2014 consider splitting the maintained shapes across rows / partitions.`,
|
|
735
|
+
entity: metadata.prefix
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// src/linter/rules/fan-out.ts
|
|
740
|
+
var MAX_MAINTENANCE_HOPS = 1;
|
|
741
|
+
var fanOutRule = {
|
|
742
|
+
id: "fan-out",
|
|
743
|
+
severity: "warning",
|
|
744
|
+
check(metadata, registry) {
|
|
745
|
+
const results = [];
|
|
746
|
+
for (const relation of maintainedRelations(metadata)) {
|
|
747
|
+
const point = checkDestinationFanOut(metadata, relation);
|
|
748
|
+
if (point) results.push(point);
|
|
749
|
+
}
|
|
750
|
+
const chain = maintenanceChain(metadata, registry, /* @__PURE__ */ new Set([modelKey(metadata)]));
|
|
751
|
+
if (chain.hops > MAX_MAINTENANCE_HOPS) {
|
|
752
|
+
results.push({
|
|
753
|
+
ruleId: "fan-out",
|
|
754
|
+
severity: "warning",
|
|
755
|
+
message: `Entity '${chain.root}' is the root of a maintenance chain ${chain.hops} hops deep, terminating at '${logicalName(metadata)}': a single '${chain.root}' lifecycle event cascades through ${chain.hops} maintenance writes \u2014 each maintained row is itself the trigger source of the next hop \u2014 until it reaches '${logicalName(metadata)}'. A multi-step maintenance fan-out amplifies one write into many; flatten the chain or move downstream hops to a stream (\`updateMode: 'stream'\`).`,
|
|
756
|
+
entity: metadata.prefix
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
return results;
|
|
760
|
+
}
|
|
761
|
+
};
|
|
762
|
+
function checkDestinationFanOut(metadata, relation) {
|
|
763
|
+
const ownerKeyFields = Object.values(relation.keyBinding);
|
|
764
|
+
if (ownerKeyFields.length === 0) return null;
|
|
765
|
+
let resolved;
|
|
766
|
+
try {
|
|
767
|
+
resolved = resolveKey(ownerKeyFields, metadata);
|
|
768
|
+
} catch {
|
|
769
|
+
return null;
|
|
770
|
+
}
|
|
771
|
+
const isPoint = !resolved.partial && (resolved.type === "pk" || resolved.type === "gsi" && resolved.unique);
|
|
772
|
+
if (isPoint) return null;
|
|
773
|
+
const shape = resolved.partial ? `a partial ${resolved.type.toUpperCase()} (a begins_with range of rows)` : `a non-unique ${resolved.type.toUpperCase()} (many matching rows)`;
|
|
774
|
+
return {
|
|
775
|
+
ruleId: "fan-out",
|
|
776
|
+
severity: "warning",
|
|
777
|
+
message: `Maintained relation '${relation.propertyName}' on '${logicalName(metadata)}' binds its destination row by [${ownerKeyFields.map((f) => `'${f}'`).join(", ")}], which resolves to ${shape} on '${logicalName(metadata)}'. A single source event then fans its maintenance write out across every matching destination row in one mutation (unbounded fan-out). Bind the destination's full primary key (or a unique GSI) so each source event maintains exactly one row.`,
|
|
778
|
+
entity: metadata.prefix,
|
|
779
|
+
field: relation.propertyName
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
function modelKey(metadata) {
|
|
783
|
+
return logicalName(metadata);
|
|
784
|
+
}
|
|
785
|
+
function maintenanceChain(metadata, registry, visited) {
|
|
786
|
+
const maintained = maintainedRelations(metadata);
|
|
787
|
+
if (maintained.length === 0) return { hops: 0, root: logicalName(metadata) };
|
|
788
|
+
let best = { hops: 0, root: logicalName(metadata) };
|
|
789
|
+
for (const relation of maintained) {
|
|
790
|
+
let targetMeta;
|
|
791
|
+
try {
|
|
792
|
+
targetMeta = registry.get(relation.targetFactory());
|
|
793
|
+
} catch {
|
|
794
|
+
continue;
|
|
795
|
+
}
|
|
796
|
+
const key2 = modelKey(targetMeta);
|
|
797
|
+
if (visited.has(key2)) continue;
|
|
798
|
+
const next = new Set(visited);
|
|
799
|
+
next.add(key2);
|
|
800
|
+
const upstream = maintenanceChain(targetMeta, registry, next);
|
|
801
|
+
if (1 + upstream.hops > best.hops) {
|
|
802
|
+
best = { hops: 1 + upstream.hops, root: upstream.root };
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
return best;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// src/linter/rules/multi-maintainer-same-row.ts
|
|
809
|
+
var multiMaintainerSameRowRule = {
|
|
810
|
+
id: "multi-maintainer-same-row",
|
|
811
|
+
severity: "error",
|
|
812
|
+
check(metadata, _registry) {
|
|
813
|
+
const maintained = maintainedRelations(metadata);
|
|
814
|
+
if (maintained.length < 2) return [];
|
|
815
|
+
const owner = logicalName(metadata);
|
|
816
|
+
const groups = /* @__PURE__ */ new Map();
|
|
817
|
+
for (const relation of maintained) {
|
|
818
|
+
const rowKey = destinationRowKey(owner, relation);
|
|
819
|
+
for (const trigger of relation.options?.write?.maintainedOn ?? []) {
|
|
820
|
+
const groupKey = `${trigger} ${rowKey}`;
|
|
821
|
+
const group = groups.get(groupKey);
|
|
822
|
+
if (group) group.props.push(relation.propertyName);
|
|
823
|
+
else groups.set(groupKey, { trigger, props: [relation.propertyName] });
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
const results = [];
|
|
827
|
+
for (const { trigger, props } of groups.values()) {
|
|
828
|
+
if (props.length < 2) continue;
|
|
829
|
+
results.push({
|
|
830
|
+
ruleId: "multi-maintainer-same-row",
|
|
831
|
+
severity: "error",
|
|
832
|
+
message: `Maintained relations [${props.map((p) => `'${p}'`).join(", ")}] on '${owner}' all fire on trigger '${trigger}' and write the SAME destination row. Phase 1 maintenance is "1 mutation \xD7 1 target row = 1 effect" (\u8AD6\u70B92 = b): a single mutation cannot touch one row with ${props.length} maintain effects \u2014 DynamoDB rejects two writes to the same key in one TransactWriteItems (cf. #96). Merge the projections into one maintained relation, or split them onto distinct destination rows / triggers.`,
|
|
833
|
+
entity: metadata.prefix,
|
|
834
|
+
field: props[0]
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
return results;
|
|
838
|
+
}
|
|
839
|
+
};
|
|
840
|
+
function destinationRowKey(owner, relation) {
|
|
841
|
+
const parts = Object.entries(relation.keyBinding).map(([sourceField, ownerField]) => `${ownerField}=$.entity.${sourceField}`).sort();
|
|
842
|
+
return `${owner}#${parts.join("&")}`;
|
|
843
|
+
}
|
|
844
|
+
|
|
414
845
|
// src/linter/default-linter.ts
|
|
415
846
|
function createDefaultLinter() {
|
|
416
847
|
const linter = new Linter();
|
|
@@ -419,6 +850,12 @@ function createDefaultLinter() {
|
|
|
419
850
|
linter.addRule(gsiAmbiguityRule);
|
|
420
851
|
linter.addRule(missingGsiRule);
|
|
421
852
|
linter.addRule(relationDepthRule);
|
|
853
|
+
linter.addRule(samePartitionPresetRule);
|
|
854
|
+
linter.addRule(missingContextRule);
|
|
855
|
+
linter.addRule(embeddedSnapshotSizeRule);
|
|
856
|
+
linter.addRule(hotPartitionRule);
|
|
857
|
+
linter.addRule(fanOutRule);
|
|
858
|
+
linter.addRule(multiMaintainerSameRowRule);
|
|
422
859
|
return linter;
|
|
423
860
|
}
|
|
424
861
|
|
|
@@ -426,8 +863,27 @@ function createDefaultLinter() {
|
|
|
426
863
|
var MetadataRegistry = class {
|
|
427
864
|
static store = /* @__PURE__ */ new Map();
|
|
428
865
|
static _linter = createDefaultLinter();
|
|
866
|
+
/**
|
|
867
|
+
* A monotonically-increasing counter bumped on every change to the set of
|
|
868
|
+
* registered models ({@link register} / {@link clear}). It lets a consumer that
|
|
869
|
+
* caches a structure derived from the whole registry (e.g. the maintenance graph
|
|
870
|
+
* in `src/spec/mutation-command.ts`) detect that the registry has changed since
|
|
871
|
+
* the cache was built and rebuild — closing the silent-drop window where a model
|
|
872
|
+
* registered after a cache build would be invisible to it. Only the membership
|
|
873
|
+
* of {@link store} changes here; per-model lazy finalize ({@link finalize}) does
|
|
874
|
+
* not bump it (it adds no relations/triggers, so it cannot change the graph).
|
|
875
|
+
*/
|
|
876
|
+
static _generation = 0;
|
|
877
|
+
/**
|
|
878
|
+
* The current registry generation (see {@link _generation}). A consumer caches
|
|
879
|
+
* the value it built against and rebuilds when it no longer matches.
|
|
880
|
+
*/
|
|
881
|
+
static get generation() {
|
|
882
|
+
return this._generation;
|
|
883
|
+
}
|
|
429
884
|
static register(target, metadata) {
|
|
430
885
|
this.store.set(target, { ...metadata, _finalized: false });
|
|
886
|
+
this._generation++;
|
|
431
887
|
}
|
|
432
888
|
static get(target) {
|
|
433
889
|
const meta = this.store.get(target);
|
|
@@ -466,6 +922,7 @@ var MetadataRegistry = class {
|
|
|
466
922
|
static clear() {
|
|
467
923
|
this.store.clear();
|
|
468
924
|
this._linter = new Linter();
|
|
925
|
+
this._generation++;
|
|
469
926
|
}
|
|
470
927
|
static finalize(target, meta) {
|
|
471
928
|
for (const name of Object.getOwnPropertyNames(target)) {
|
|
@@ -834,6 +1291,27 @@ var RetryingExecutor = class {
|
|
|
834
1291
|
}
|
|
835
1292
|
};
|
|
836
1293
|
|
|
1294
|
+
// src/middleware/registry.ts
|
|
1295
|
+
var MiddlewareRegistry = class {
|
|
1296
|
+
list = [];
|
|
1297
|
+
/** Register a middleware (appended last — it runs last in FIFO `before*`). */
|
|
1298
|
+
use(mw) {
|
|
1299
|
+
this.list.push(mw);
|
|
1300
|
+
}
|
|
1301
|
+
/** Remove all registered middleware (teardown / tests). */
|
|
1302
|
+
clear() {
|
|
1303
|
+
this.list = [];
|
|
1304
|
+
}
|
|
1305
|
+
/**
|
|
1306
|
+
* A point-in-time, registration-ordered snapshot. A read takes this once at
|
|
1307
|
+
* entry (R1) and threads it down, so its hook set is stable for the whole read
|
|
1308
|
+
* even if middleware is registered / cleared mid-flight.
|
|
1309
|
+
*/
|
|
1310
|
+
snapshot() {
|
|
1311
|
+
return this.list.slice();
|
|
1312
|
+
}
|
|
1313
|
+
};
|
|
1314
|
+
|
|
837
1315
|
// src/client/ClientManager.ts
|
|
838
1316
|
var ClientManager = class {
|
|
839
1317
|
static client = null;
|
|
@@ -853,6 +1331,15 @@ var ClientManager = class {
|
|
|
853
1331
|
* read at call time by the default {@link RetryingExecutor}.
|
|
854
1332
|
*/
|
|
855
1333
|
static retryPolicy = null;
|
|
1334
|
+
/**
|
|
1335
|
+
* The host-runtime read-middleware registry (issue #50 / #138) — a process-wide
|
|
1336
|
+
* ordered list of {@link Middleware}, the same global-config + injectable-seam
|
|
1337
|
+
* pattern as {@link retryPolicy} / {@link executor}. Registered via
|
|
1338
|
+
* {@link DDBModel.use} / {@link use}, snapshotted per read, and NEVER serialized
|
|
1339
|
+
* (it never touches the planner / spec / `operations.json`, so the bridge #48 is
|
|
1340
|
+
* unaffected). {@link reset} clears it.
|
|
1341
|
+
*/
|
|
1342
|
+
static middleware = new MiddlewareRegistry();
|
|
856
1343
|
static setClient(client) {
|
|
857
1344
|
this.client = client;
|
|
858
1345
|
this.documentClient = null;
|
|
@@ -919,12 +1406,34 @@ var ClientManager = class {
|
|
|
919
1406
|
static getRetryPolicy() {
|
|
920
1407
|
return this.retryPolicy;
|
|
921
1408
|
}
|
|
1409
|
+
/**
|
|
1410
|
+
* Register a read {@link Middleware} (issue #50 / #138). Appended last, so its
|
|
1411
|
+
* `before*` hooks run last (FIFO) and its `afterFetch` / `onError` hooks run
|
|
1412
|
+
* first (LIFO). Mirrors {@link setRetryPolicy}; backs {@link DDBModel.use}.
|
|
1413
|
+
*/
|
|
1414
|
+
static use(mw) {
|
|
1415
|
+
this.middleware.use(mw);
|
|
1416
|
+
}
|
|
1417
|
+
/** Remove every registered read middleware (teardown / tests). Backs {@link DDBModel.clearMiddleware}. */
|
|
1418
|
+
static clearMiddleware() {
|
|
1419
|
+
this.middleware.clear();
|
|
1420
|
+
}
|
|
1421
|
+
/**
|
|
1422
|
+
* A point-in-time, registration-ordered snapshot of the registered read
|
|
1423
|
+
* middleware. A read takes this once at entry (R1) and threads it down, so the
|
|
1424
|
+
* read's hook set stays stable even if middleware is registered / cleared
|
|
1425
|
+
* mid-flight.
|
|
1426
|
+
*/
|
|
1427
|
+
static getMiddleware() {
|
|
1428
|
+
return this.middleware.snapshot();
|
|
1429
|
+
}
|
|
922
1430
|
/** @internal — for testing only */
|
|
923
1431
|
static reset() {
|
|
924
1432
|
this.client = null;
|
|
925
1433
|
this.documentClient = null;
|
|
926
1434
|
this.executor = null;
|
|
927
1435
|
this.retryPolicy = null;
|
|
1436
|
+
this.middleware.clear();
|
|
928
1437
|
}
|
|
929
1438
|
};
|
|
930
1439
|
|
|
@@ -1440,6 +1949,69 @@ function serializeFieldValue(value, fieldMeta) {
|
|
|
1440
1949
|
return value;
|
|
1441
1950
|
}
|
|
1442
1951
|
|
|
1952
|
+
// src/expression/update-expression.ts
|
|
1953
|
+
function isPlainObject(value) {
|
|
1954
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof Date);
|
|
1955
|
+
}
|
|
1956
|
+
function flattenChanges(obj, parentPath, embeddedFieldNames) {
|
|
1957
|
+
const sets = [];
|
|
1958
|
+
const removes = [];
|
|
1959
|
+
for (const [fieldName, value] of Object.entries(obj)) {
|
|
1960
|
+
const currentPath = [...parentPath, fieldName];
|
|
1961
|
+
if (value === void 0) {
|
|
1962
|
+
removes.push({ path: currentPath });
|
|
1963
|
+
} else if (embeddedFieldNames.has(currentPath[0]) && isPlainObject(value)) {
|
|
1964
|
+
const nested = flattenChanges(value, currentPath, embeddedFieldNames);
|
|
1965
|
+
sets.push(...nested.sets);
|
|
1966
|
+
removes.push(...nested.removes);
|
|
1967
|
+
} else {
|
|
1968
|
+
sets.push({ path: currentPath, value });
|
|
1969
|
+
}
|
|
1970
|
+
}
|
|
1971
|
+
return { sets, removes };
|
|
1972
|
+
}
|
|
1973
|
+
function buildUpdateExpression(changes, embeddedFieldNames) {
|
|
1974
|
+
const { sets, removes } = flattenChanges(changes, [], embeddedFieldNames);
|
|
1975
|
+
if (sets.length === 0 && removes.length === 0) {
|
|
1976
|
+
throw new Error("No changes provided for update.");
|
|
1977
|
+
}
|
|
1978
|
+
const names = {};
|
|
1979
|
+
const values = {};
|
|
1980
|
+
let valueCounter = 0;
|
|
1981
|
+
const setClauses = [];
|
|
1982
|
+
for (const op of sets) {
|
|
1983
|
+
const pathExpr = op.path.map((segment) => {
|
|
1984
|
+
const nameKey = `#${segment}`;
|
|
1985
|
+
names[nameKey] = segment;
|
|
1986
|
+
return nameKey;
|
|
1987
|
+
}).join(".");
|
|
1988
|
+
const valueKey = `:val${valueCounter++}`;
|
|
1989
|
+
values[valueKey] = op.value;
|
|
1990
|
+
setClauses.push(`${pathExpr} = ${valueKey}`);
|
|
1991
|
+
}
|
|
1992
|
+
const removeClauses = [];
|
|
1993
|
+
for (const op of removes) {
|
|
1994
|
+
const pathExpr = op.path.map((segment) => {
|
|
1995
|
+
const nameKey = `#${segment}`;
|
|
1996
|
+
names[nameKey] = segment;
|
|
1997
|
+
return nameKey;
|
|
1998
|
+
}).join(".");
|
|
1999
|
+
removeClauses.push(pathExpr);
|
|
2000
|
+
}
|
|
2001
|
+
const parts = [];
|
|
2002
|
+
if (setClauses.length > 0) {
|
|
2003
|
+
parts.push(`SET ${setClauses.join(", ")}`);
|
|
2004
|
+
}
|
|
2005
|
+
if (removeClauses.length > 0) {
|
|
2006
|
+
parts.push(`REMOVE ${removeClauses.join(", ")}`);
|
|
2007
|
+
}
|
|
2008
|
+
return {
|
|
2009
|
+
expression: parts.join(" "),
|
|
2010
|
+
names,
|
|
2011
|
+
values
|
|
2012
|
+
};
|
|
2013
|
+
}
|
|
2014
|
+
|
|
1443
2015
|
// src/metadata/segments.ts
|
|
1444
2016
|
var SEGMENT_DELIMITER = "#";
|
|
1445
2017
|
function segmentComplete(segment, values) {
|
|
@@ -1579,6 +2151,34 @@ function missingGsiFields(gsi2, available) {
|
|
|
1579
2151
|
return gsi2.inputFieldNames.filter((f) => available[f] === void 0);
|
|
1580
2152
|
}
|
|
1581
2153
|
|
|
2154
|
+
// src/hydrator/hidden-key.ts
|
|
2155
|
+
var GRAPHDDB_KEY = /* @__PURE__ */ Symbol("graphddb:key");
|
|
2156
|
+
function attachHiddenKey(result, raw) {
|
|
2157
|
+
const pk = raw.PK;
|
|
2158
|
+
if (typeof pk !== "string") {
|
|
2159
|
+
throw new Error(
|
|
2160
|
+
"Cannot attach updatable key: raw item is missing a string `PK` attribute."
|
|
2161
|
+
);
|
|
2162
|
+
}
|
|
2163
|
+
const sk = raw.SK;
|
|
2164
|
+
const value = {
|
|
2165
|
+
PK: pk,
|
|
2166
|
+
SK: typeof sk === "string" ? sk : ""
|
|
2167
|
+
};
|
|
2168
|
+
Object.defineProperty(result, GRAPHDDB_KEY, {
|
|
2169
|
+
value,
|
|
2170
|
+
enumerable: false,
|
|
2171
|
+
writable: false,
|
|
2172
|
+
configurable: false
|
|
2173
|
+
});
|
|
2174
|
+
return result;
|
|
2175
|
+
}
|
|
2176
|
+
function readHiddenKey(entity) {
|
|
2177
|
+
const value = entity[GRAPHDDB_KEY];
|
|
2178
|
+
if (value === void 0) return void 0;
|
|
2179
|
+
return value;
|
|
2180
|
+
}
|
|
2181
|
+
|
|
1582
2182
|
// src/capture/registry.ts
|
|
1583
2183
|
var ChangeCaptureRegistryImpl = class {
|
|
1584
2184
|
subscribers = /* @__PURE__ */ new Set();
|
|
@@ -1648,7 +2248,279 @@ function captureWrite(record) {
|
|
|
1648
2248
|
ChangeCaptureRegistry.emit(record);
|
|
1649
2249
|
}
|
|
1650
2250
|
|
|
1651
|
-
// src/
|
|
2251
|
+
// src/middleware/runtime.ts
|
|
2252
|
+
function opKind(operation) {
|
|
2253
|
+
return operation.type;
|
|
2254
|
+
}
|
|
2255
|
+
var MiddlewareRuntime = class {
|
|
2256
|
+
/** The frozen, registration-ordered middleware snapshot for this read. */
|
|
2257
|
+
chain;
|
|
2258
|
+
/** The host-injected per-call context (`{}` when the read passed none). */
|
|
2259
|
+
context;
|
|
2260
|
+
constructor(chain, context) {
|
|
2261
|
+
this.chain = chain;
|
|
2262
|
+
this.context = context;
|
|
2263
|
+
}
|
|
2264
|
+
/** `true` when at least one middleware is registered for this read. */
|
|
2265
|
+
get active() {
|
|
2266
|
+
return this.chain.length > 0;
|
|
2267
|
+
}
|
|
2268
|
+
/**
|
|
2269
|
+
* Build the request-level context (R1 / R4 / R5 share it). Pure — no hooks run
|
|
2270
|
+
* — so a caller can create it up front and still reference it from R5 even if
|
|
2271
|
+
* R1 throws mid-chain.
|
|
2272
|
+
*/
|
|
2273
|
+
requestCtx(kind, model, params) {
|
|
2274
|
+
return { kind, model, context: this.context, state: {}, params };
|
|
2275
|
+
}
|
|
2276
|
+
/**
|
|
2277
|
+
* R1 — request entry, before key-resolution / plan. Builds the
|
|
2278
|
+
* {@link ReadRequestCtx} then runs every `read.before` FIFO; a hook may mutate
|
|
2279
|
+
* `ctx.params` and may `throw` to cancel the read. Returns the (possibly
|
|
2280
|
+
* hook-mutated) ctx so the caller can re-read `params` and reuse the SAME ctx
|
|
2281
|
+
* for R4 / R5 (shared `state`).
|
|
2282
|
+
*/
|
|
2283
|
+
async runRequestBefore(kind, model, params) {
|
|
2284
|
+
const ctx = this.requestCtx(kind, model, params);
|
|
2285
|
+
for (const mw of this.chain) {
|
|
2286
|
+
const before = mw.read?.before;
|
|
2287
|
+
if (before) await before(ctx);
|
|
2288
|
+
}
|
|
2289
|
+
return ctx;
|
|
2290
|
+
}
|
|
2291
|
+
/**
|
|
2292
|
+
* R4 — final hydrated, relation-merged result. Runs every `read.afterFetch`
|
|
2293
|
+
* LIFO, threading each hook's return value into the next (onion), and returns
|
|
2294
|
+
* the final (possibly replaced) result. `ctx` is the SAME object R1 produced,
|
|
2295
|
+
* so a `before`/`afterFetch` pair shares `ctx.state`.
|
|
2296
|
+
*/
|
|
2297
|
+
async runRequestAfter(ctx, result) {
|
|
2298
|
+
let current = result;
|
|
2299
|
+
for (let i = this.chain.length - 1; i >= 0; i--) {
|
|
2300
|
+
const afterFetch = this.chain[i].read?.afterFetch;
|
|
2301
|
+
if (afterFetch) current = await afterFetch(ctx, current);
|
|
2302
|
+
}
|
|
2303
|
+
return current;
|
|
2304
|
+
}
|
|
2305
|
+
/**
|
|
2306
|
+
* R5 (request-level) — runs every `read.onError` LIFO. A hook may **recover**
|
|
2307
|
+
* by RETURNING a non-`undefined` value: it becomes the read's resolved result
|
|
2308
|
+
* in place of throwing, and the FIRST such hook (in LIFO order) wins and
|
|
2309
|
+
* short-circuits the rest of the chain. If every hook declines (returns
|
|
2310
|
+
* `undefined`/`void`), the original error rethrows — preserving the
|
|
2311
|
+
* "failed read still rejects" default. (Proposal R5 + appendix A: onError may
|
|
2312
|
+
* "rethrow / recover"; hooks are unrestricted.)
|
|
2313
|
+
*
|
|
2314
|
+
* The recovered value is returned as `T` (the read's result type); its shape
|
|
2315
|
+
* is the host's responsibility — a hook recovering a `query` should return an
|
|
2316
|
+
* item / `null`, one recovering a `list` a `{ items, cursor }`.
|
|
2317
|
+
*/
|
|
2318
|
+
async runRequestError(ctx, err3) {
|
|
2319
|
+
for (let i = this.chain.length - 1; i >= 0; i--) {
|
|
2320
|
+
const onError = this.chain[i].read?.onError;
|
|
2321
|
+
if (!onError) continue;
|
|
2322
|
+
const recovered = await onError(ctx, err3);
|
|
2323
|
+
if (recovered !== void 0) return recovered;
|
|
2324
|
+
}
|
|
2325
|
+
throw err3;
|
|
2326
|
+
}
|
|
2327
|
+
/**
|
|
2328
|
+
* Drive ONE physical op (root read or any fan-out fetch) through R2 → send →
|
|
2329
|
+
* R3, with R5 (op-level) on failure:
|
|
2330
|
+
*
|
|
2331
|
+
* 1. R2 (`read.op.before`, FIFO) — may mutate `ctx.operation`, may `throw`;
|
|
2332
|
+
* 2. the caller's `send` runs against the (possibly mutated) operation;
|
|
2333
|
+
* 3. R3 (`read.op.afterFetch`, LIFO) — transforms the raw items (onion);
|
|
2334
|
+
* 4. on any throw from `send` (or R2 / R3), R5 (`read.op.onError`, LIFO) runs:
|
|
2335
|
+
* a hook may **recover** by RETURNING an `Item[]` (those become this op's
|
|
2336
|
+
* items and the pipeline continues); the FIRST hook that returns items wins
|
|
2337
|
+
* and short-circuits the chain. If none recovers, the original error
|
|
2338
|
+
* rethrows.
|
|
2339
|
+
*
|
|
2340
|
+
* Returns the (possibly R3-replaced, or R5-recovered) items. The `send` closure
|
|
2341
|
+
* receives the final operation so a caller that built its operation from
|
|
2342
|
+
* `ctx.operation` before this call still observes an R2 mutation (the caller
|
|
2343
|
+
* MUST send `ctx.operation`, which this method passes through).
|
|
2344
|
+
*/
|
|
2345
|
+
async runOp(operation, relationPath, model, send) {
|
|
2346
|
+
const ctx = {
|
|
2347
|
+
kind: opKind(operation),
|
|
2348
|
+
model,
|
|
2349
|
+
context: this.context,
|
|
2350
|
+
state: {},
|
|
2351
|
+
operation,
|
|
2352
|
+
relationPath
|
|
2353
|
+
};
|
|
2354
|
+
try {
|
|
2355
|
+
for (const mw of this.chain) {
|
|
2356
|
+
const before = mw.read?.op?.before;
|
|
2357
|
+
if (before) await before(ctx);
|
|
2358
|
+
}
|
|
2359
|
+
let items = await send(ctx.operation);
|
|
2360
|
+
for (let i = this.chain.length - 1; i >= 0; i--) {
|
|
2361
|
+
const afterFetch = this.chain[i].read?.op?.afterFetch;
|
|
2362
|
+
if (afterFetch) items = await afterFetch(ctx, items);
|
|
2363
|
+
}
|
|
2364
|
+
return items;
|
|
2365
|
+
} catch (err3) {
|
|
2366
|
+
for (let i = this.chain.length - 1; i >= 0; i--) {
|
|
2367
|
+
const onError = this.chain[i].read?.op?.onError;
|
|
2368
|
+
if (!onError) continue;
|
|
2369
|
+
const recovered = await onError(ctx, err3);
|
|
2370
|
+
if (recovered !== void 0) return recovered;
|
|
2371
|
+
}
|
|
2372
|
+
throw err3;
|
|
2373
|
+
}
|
|
2374
|
+
}
|
|
2375
|
+
};
|
|
2376
|
+
var NO_MIDDLEWARE = new MiddlewareRuntime([], {});
|
|
2377
|
+
var WriteRuntime = class {
|
|
2378
|
+
chain;
|
|
2379
|
+
context;
|
|
2380
|
+
constructor(chain, context) {
|
|
2381
|
+
this.chain = chain;
|
|
2382
|
+
this.context = context;
|
|
2383
|
+
}
|
|
2384
|
+
/** `true` when at least one middleware is registered for this write. */
|
|
2385
|
+
get active() {
|
|
2386
|
+
return this.chain.length > 0;
|
|
2387
|
+
}
|
|
2388
|
+
/**
|
|
2389
|
+
* `true` when at least one registered middleware sets a `write.after` (W2) hook.
|
|
2390
|
+
* The single-op write path (issue #139) uses this to decide whether to request
|
|
2391
|
+
* the pre-write image (`ReturnValues: ALL_OLD`) so W2 receives a real
|
|
2392
|
+
* `{ oldImage, newImage }` rather than `{}` — the cost is paid only when a W2
|
|
2393
|
+
* hook actually exists.
|
|
2394
|
+
*/
|
|
2395
|
+
get hasWriteAfter() {
|
|
2396
|
+
return this.chain.some((mw) => mw.write?.after !== void 0);
|
|
2397
|
+
}
|
|
2398
|
+
/**
|
|
2399
|
+
* Build a W1/W2/W5 logical-write context (pure — no hooks run). The caller can
|
|
2400
|
+
* create it up front and reuse the SAME object across W1 → W2 / W5 (shared
|
|
2401
|
+
* `state`). `transaction` is set when the op is part of an atomic batch.
|
|
2402
|
+
*/
|
|
2403
|
+
writeCtx(kind, model, input, transaction) {
|
|
2404
|
+
return transaction !== void 0 ? { kind, model, context: this.context, state: {}, input, transaction } : { kind, model, context: this.context, state: {}, input };
|
|
2405
|
+
}
|
|
2406
|
+
/**
|
|
2407
|
+
* W1 — logical write, before effect derivation. Runs every `write.before` FIFO;
|
|
2408
|
+
* a hook may mutate `ctx.input` / `ctx.kind` (incl. the delete→update rewrite)
|
|
2409
|
+
* and may `throw` to cancel (in a transaction, the throw propagates and aborts
|
|
2410
|
+
* the whole batch). The caller re-reads `ctx.kind` / `ctx.input` after this
|
|
2411
|
+
* returns so a rewrite flows into derivation.
|
|
2412
|
+
*/
|
|
2413
|
+
async runWriteBefore(ctx) {
|
|
2414
|
+
for (const mw of this.chain) {
|
|
2415
|
+
const before = mw.write?.before;
|
|
2416
|
+
if (before) await before(ctx);
|
|
2417
|
+
}
|
|
2418
|
+
}
|
|
2419
|
+
/**
|
|
2420
|
+
* W2 — logical write, after commit. Runs every `write.after` LIFO with the
|
|
2421
|
+
* committed change. Observe-only (no return threading); the SAME ctx W1
|
|
2422
|
+
* produced is passed so a before/after pair shares `ctx.state`.
|
|
2423
|
+
*/
|
|
2424
|
+
async runWriteAfter(ctx, change2) {
|
|
2425
|
+
for (let i = this.chain.length - 1; i >= 0; i--) {
|
|
2426
|
+
const after = this.chain[i].write?.after;
|
|
2427
|
+
if (after) await after(ctx, change2);
|
|
2428
|
+
}
|
|
2429
|
+
}
|
|
2430
|
+
/**
|
|
2431
|
+
* W5 (logical-level) — runs every `write.onError` LIFO. A hook may **recover**
|
|
2432
|
+
* by RETURNING a non-`undefined` value (the first such hook wins and
|
|
2433
|
+
* short-circuits the chain); the value becomes the write's resolved value.
|
|
2434
|
+
* Otherwise the original error rethrows (symmetric with the read `onError`).
|
|
2435
|
+
*/
|
|
2436
|
+
async runWriteError(ctx, err3) {
|
|
2437
|
+
for (let i = this.chain.length - 1; i >= 0; i--) {
|
|
2438
|
+
const onError = this.chain[i].write?.onError;
|
|
2439
|
+
if (!onError) continue;
|
|
2440
|
+
const recovered = await onError(ctx, err3);
|
|
2441
|
+
if (recovered !== void 0) return recovered;
|
|
2442
|
+
}
|
|
2443
|
+
throw err3;
|
|
2444
|
+
}
|
|
2445
|
+
/** Build a W3/W4/W5 persist context (pure — no hooks run). */
|
|
2446
|
+
persistCtx(items, origins, transaction) {
|
|
2447
|
+
return transaction !== void 0 ? { items, origins, context: this.context, state: {}, transaction } : { items, origins, context: this.context, state: {} };
|
|
2448
|
+
}
|
|
2449
|
+
/**
|
|
2450
|
+
* Drive the PHYSICAL persist of one composed batch through W3 → send → W4, with
|
|
2451
|
+
* W5 (persist-level) on failure:
|
|
2452
|
+
*
|
|
2453
|
+
* 1. W3 (`write.persist.before`, FIFO) — may mutate `ctx.items`, may `throw`;
|
|
2454
|
+
* 2. the caller's `send` runs against the (possibly mutated) `ctx.items`;
|
|
2455
|
+
* 3. W4 (`write.persist.after`, LIFO) — observes the executor results;
|
|
2456
|
+
* 4. on any throw, W5 (`write.persist.onError`, LIFO) runs: a hook may
|
|
2457
|
+
* **recover** by returning a value (treated as the send's result; W4 still
|
|
2458
|
+
* runs with it). If none recovers, the original error rethrows.
|
|
2459
|
+
*
|
|
2460
|
+
* The `send` closure receives the final `ctx.items` so a caller that sends what
|
|
2461
|
+
* this method passes through observes a W3 mutation on the ACTUAL send. Returns
|
|
2462
|
+
* the persist result (the executor's, or a W5-recovered value).
|
|
2463
|
+
*/
|
|
2464
|
+
async runPersist(ctx, send) {
|
|
2465
|
+
let results;
|
|
2466
|
+
try {
|
|
2467
|
+
for (const mw of this.chain) {
|
|
2468
|
+
const before = mw.write?.persist?.before;
|
|
2469
|
+
if (before) await before(ctx);
|
|
2470
|
+
}
|
|
2471
|
+
results = await send(ctx.items);
|
|
2472
|
+
} catch (err3) {
|
|
2473
|
+
let recovered;
|
|
2474
|
+
let didRecover = false;
|
|
2475
|
+
for (let i = this.chain.length - 1; i >= 0; i--) {
|
|
2476
|
+
const onError = this.chain[i].write?.persist?.onError;
|
|
2477
|
+
if (!onError) continue;
|
|
2478
|
+
const r = await onError(ctx, err3);
|
|
2479
|
+
if (r !== void 0) {
|
|
2480
|
+
recovered = r;
|
|
2481
|
+
didRecover = true;
|
|
2482
|
+
break;
|
|
2483
|
+
}
|
|
2484
|
+
}
|
|
2485
|
+
if (!didRecover) throw err3;
|
|
2486
|
+
results = recovered;
|
|
2487
|
+
}
|
|
2488
|
+
for (let i = this.chain.length - 1; i >= 0; i--) {
|
|
2489
|
+
const after = this.chain[i].write?.persist?.after;
|
|
2490
|
+
if (after) await after(ctx, results);
|
|
2491
|
+
}
|
|
2492
|
+
return results;
|
|
2493
|
+
}
|
|
2494
|
+
};
|
|
2495
|
+
var NO_WRITE_MIDDLEWARE = new WriteRuntime([], {});
|
|
2496
|
+
|
|
2497
|
+
// src/middleware/context.ts
|
|
2498
|
+
function buildReadRuntime(context) {
|
|
2499
|
+
const chain = ClientManager.getMiddleware();
|
|
2500
|
+
if (chain.length === 0) return NO_MIDDLEWARE;
|
|
2501
|
+
return new MiddlewareRuntime(chain, context ?? {});
|
|
2502
|
+
}
|
|
2503
|
+
function buildWriteRuntime(context) {
|
|
2504
|
+
const chain = ClientManager.getMiddleware();
|
|
2505
|
+
if (chain.length === 0) return NO_WRITE_MIDDLEWARE;
|
|
2506
|
+
return new WriteRuntime(chain, context ?? {});
|
|
2507
|
+
}
|
|
2508
|
+
var ctxModelCache = /* @__PURE__ */ new WeakMap();
|
|
2509
|
+
function ctxModelFor(modelClass) {
|
|
2510
|
+
const cached2 = ctxModelCache.get(modelClass);
|
|
2511
|
+
if (cached2) return cached2;
|
|
2512
|
+
const asModel = modelClass.asModel;
|
|
2513
|
+
if (typeof asModel !== "function") {
|
|
2514
|
+
throw new Error(
|
|
2515
|
+
"graphddb middleware: cannot resolve a ModelStatic for the read target (the model class has no static asModel()). This is an internal invariant."
|
|
2516
|
+
);
|
|
2517
|
+
}
|
|
2518
|
+
const resolved = asModel.call(modelClass);
|
|
2519
|
+
ctxModelCache.set(modelClass, resolved);
|
|
2520
|
+
return resolved;
|
|
2521
|
+
}
|
|
2522
|
+
|
|
2523
|
+
// src/operations/put.ts
|
|
1652
2524
|
function buildPutInput(modelClass, item, options) {
|
|
1653
2525
|
const meta = MetadataRegistry.get(modelClass);
|
|
1654
2526
|
if (!meta.primaryKey) {
|
|
@@ -1714,12 +2586,21 @@ function buildPutInput(modelClass, item, options) {
|
|
|
1714
2586
|
return putInput;
|
|
1715
2587
|
}
|
|
1716
2588
|
async function executePut(modelClass, item, options) {
|
|
2589
|
+
const mw = writeRuntimeFor(options);
|
|
2590
|
+
if (mw) {
|
|
2591
|
+
await executeSingleWriteWithHooks({ kind: "put", modelClass, item, options }, mw);
|
|
2592
|
+
return;
|
|
2593
|
+
}
|
|
2594
|
+
await executePutCore(modelClass, item, options);
|
|
2595
|
+
}
|
|
2596
|
+
async function executePutCore(modelClass, item, options, persistVia, forceOldImage) {
|
|
1717
2597
|
const putInput = buildPutInput(modelClass, item, options);
|
|
1718
|
-
const wantsOld = ChangeCaptureRegistry.wantsOldImage(modelClass);
|
|
1719
|
-
const
|
|
2598
|
+
const wantsOld = ChangeCaptureRegistry.wantsOldImage(modelClass) || forceOldImage === true;
|
|
2599
|
+
const send = (input) => ClientManager.getExecutor().put(input, {
|
|
1720
2600
|
...wantsOld ? { returnOldImage: true } : {},
|
|
1721
2601
|
...options?.retry !== void 0 ? { retry: options.retry } : {}
|
|
1722
2602
|
});
|
|
2603
|
+
const result = persistVia ? await persistVia(putInput, send) : await send(putInput);
|
|
1723
2604
|
if (ChangeCaptureRegistry.hasSubscribers()) {
|
|
1724
2605
|
const oldItem = result.oldItem;
|
|
1725
2606
|
captureWrite({
|
|
@@ -1733,95 +2614,163 @@ async function executePut(modelClass, item, options) {
|
|
|
1733
2614
|
}
|
|
1734
2615
|
}
|
|
1735
2616
|
|
|
1736
|
-
// src/
|
|
1737
|
-
function
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
for (const [fieldName, value] of Object.entries(obj)) {
|
|
1744
|
-
const currentPath = [...parentPath, fieldName];
|
|
1745
|
-
if (value === void 0) {
|
|
1746
|
-
removes.push({ path: currentPath });
|
|
1747
|
-
} else if (embeddedFieldNames.has(currentPath[0]) && isPlainObject(value)) {
|
|
1748
|
-
const nested = flattenChanges(value, currentPath, embeddedFieldNames);
|
|
1749
|
-
sets.push(...nested.sets);
|
|
1750
|
-
removes.push(...nested.removes);
|
|
1751
|
-
} else {
|
|
1752
|
-
sets.push({ path: currentPath, value });
|
|
1753
|
-
}
|
|
1754
|
-
}
|
|
1755
|
-
return { sets, removes };
|
|
1756
|
-
}
|
|
1757
|
-
function buildUpdateExpression(changes, embeddedFieldNames) {
|
|
1758
|
-
const { sets, removes } = flattenChanges(changes, [], embeddedFieldNames);
|
|
1759
|
-
if (sets.length === 0 && removes.length === 0) {
|
|
1760
|
-
throw new Error("No changes provided for update.");
|
|
2617
|
+
// src/operations/delete.ts
|
|
2618
|
+
function buildDeleteInput(modelClass, keyObj, options) {
|
|
2619
|
+
const meta = MetadataRegistry.get(modelClass);
|
|
2620
|
+
if (!meta.primaryKey) {
|
|
2621
|
+
throw new Error(
|
|
2622
|
+
`No primary key defined for '${modelClass.name}'. Ensure the model has a static \`keys = key(...)\` definition.`
|
|
2623
|
+
);
|
|
1761
2624
|
}
|
|
1762
|
-
const
|
|
1763
|
-
const
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
setClauses.push(`${pathExpr} = ${valueKey}`);
|
|
2625
|
+
const tableName = TableMapping.resolve(meta.tableName);
|
|
2626
|
+
const fieldMap = new Map(
|
|
2627
|
+
meta.fields.map((f) => [f.propertyName, f])
|
|
2628
|
+
);
|
|
2629
|
+
const keyInput = {};
|
|
2630
|
+
for (const fieldName of meta.primaryKey.inputFieldNames) {
|
|
2631
|
+
let value = keyObj[fieldName];
|
|
2632
|
+
const fieldMeta = fieldMap.get(fieldName);
|
|
2633
|
+
if (fieldMeta && value !== void 0) {
|
|
2634
|
+
value = serializeFieldValue(value, fieldMeta);
|
|
2635
|
+
}
|
|
2636
|
+
keyInput[fieldName] = value;
|
|
1775
2637
|
}
|
|
1776
|
-
const
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
2638
|
+
const { pk, sk } = resolveSegmentedKey(
|
|
2639
|
+
meta.primaryKey.segmented,
|
|
2640
|
+
keyInput,
|
|
2641
|
+
`${modelClass.name} primary key`
|
|
2642
|
+
);
|
|
2643
|
+
const deleteInput = {
|
|
2644
|
+
TableName: tableName,
|
|
2645
|
+
Key: {
|
|
2646
|
+
PK: pk,
|
|
2647
|
+
SK: sk ?? ""
|
|
2648
|
+
}
|
|
2649
|
+
};
|
|
2650
|
+
if (options?.condition) {
|
|
2651
|
+
const condResult = buildConditionExpression(options.condition);
|
|
2652
|
+
deleteInput.ConditionExpression = condResult.expression;
|
|
2653
|
+
if (Object.keys(condResult.names).length > 0) {
|
|
2654
|
+
deleteInput.ExpressionAttributeNames = condResult.names;
|
|
2655
|
+
}
|
|
2656
|
+
if (Object.keys(condResult.values).length > 0) {
|
|
2657
|
+
deleteInput.ExpressionAttributeValues = condResult.values;
|
|
2658
|
+
}
|
|
1784
2659
|
}
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
2660
|
+
return deleteInput;
|
|
2661
|
+
}
|
|
2662
|
+
async function executeDelete(modelClass, keyObj, options) {
|
|
2663
|
+
const mw = writeRuntimeFor(options);
|
|
2664
|
+
if (mw) {
|
|
2665
|
+
await executeSingleWriteWithHooks({ kind: "delete", modelClass, key: keyObj, options }, mw);
|
|
2666
|
+
return;
|
|
1788
2667
|
}
|
|
1789
|
-
|
|
1790
|
-
|
|
2668
|
+
await executeDeleteCore(modelClass, keyObj, options);
|
|
2669
|
+
}
|
|
2670
|
+
async function executeDeleteCore(modelClass, keyObj, options, persistVia, forceOldImage) {
|
|
2671
|
+
const deleteInput = buildDeleteInput(modelClass, keyObj, options);
|
|
2672
|
+
const wantsOld = ChangeCaptureRegistry.wantsOldImage(modelClass) || forceOldImage === true;
|
|
2673
|
+
const send = (input) => ClientManager.getExecutor().delete(input, {
|
|
2674
|
+
...wantsOld ? { returnOldImage: true } : {},
|
|
2675
|
+
...options?.retry !== void 0 ? { retry: options.retry } : {}
|
|
2676
|
+
});
|
|
2677
|
+
const result = persistVia ? await persistVia(deleteInput, send) : await send(deleteInput);
|
|
2678
|
+
if (ChangeCaptureRegistry.hasSubscribers()) {
|
|
2679
|
+
const oldItem = result.oldItem;
|
|
2680
|
+
captureWrite({
|
|
2681
|
+
table: deleteInput.TableName,
|
|
2682
|
+
model: modelClass.name,
|
|
2683
|
+
op: "delete",
|
|
2684
|
+
keys: { pk: String(deleteInput.Key.PK), sk: String(deleteInput.Key.SK ?? "") },
|
|
2685
|
+
...oldItem ? { oldItem } : {}
|
|
2686
|
+
});
|
|
1791
2687
|
}
|
|
1792
|
-
return {
|
|
1793
|
-
expression: parts.join(" "),
|
|
1794
|
-
names,
|
|
1795
|
-
values
|
|
1796
|
-
};
|
|
1797
2688
|
}
|
|
1798
2689
|
|
|
1799
|
-
// src/
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
2690
|
+
// src/operations/write-hooks.ts
|
|
2691
|
+
function writeRuntimeFor(options) {
|
|
2692
|
+
const mw = buildWriteRuntime(options?.context);
|
|
2693
|
+
return mw.active ? mw : void 0;
|
|
2694
|
+
}
|
|
2695
|
+
async function executeSingleWriteWithHooks(req, mw) {
|
|
2696
|
+
const input = {};
|
|
2697
|
+
if (req.item !== void 0) input.item = { ...req.item };
|
|
2698
|
+
if (req.key !== void 0) input.key = { ...req.key };
|
|
2699
|
+
if (req.changes !== void 0) input.changes = { ...req.changes };
|
|
2700
|
+
if (req.options !== void 0) input.options = { ...req.options };
|
|
2701
|
+
const ctx = mw.writeCtx(req.kind, ctxModelFor(req.modelClass), input);
|
|
2702
|
+
try {
|
|
2703
|
+
await mw.runWriteBefore(ctx);
|
|
2704
|
+
const change2 = await persistRewrittenWrite(req.modelClass, ctx, mw);
|
|
2705
|
+
await mw.runWriteAfter(ctx, change2);
|
|
2706
|
+
} catch (err3) {
|
|
2707
|
+
await mw.runWriteError(ctx, err3);
|
|
2708
|
+
}
|
|
2709
|
+
}
|
|
2710
|
+
async function persistRewrittenWrite(modelClass, ctx, mw) {
|
|
2711
|
+
const opts = ctx.input.options;
|
|
2712
|
+
const origin = { model: ctx.model, kind: ctx.kind };
|
|
2713
|
+
const forceOldImage = mw.hasWriteAfter;
|
|
2714
|
+
const cap = {};
|
|
2715
|
+
if (ctx.kind === "put") {
|
|
2716
|
+
await executePutCore(
|
|
2717
|
+
modelClass,
|
|
2718
|
+
ctx.input.item ?? {},
|
|
2719
|
+
opts,
|
|
2720
|
+
(input, send) => runPersist(mw, { Put: input }, origin, cap, (it) => {
|
|
2721
|
+
const put = it.Put;
|
|
2722
|
+
cap.newImage = put.Item;
|
|
2723
|
+
return send(put);
|
|
2724
|
+
}),
|
|
2725
|
+
forceOldImage
|
|
2726
|
+
);
|
|
2727
|
+
return change(cap, true);
|
|
2728
|
+
}
|
|
2729
|
+
if (ctx.kind === "update") {
|
|
2730
|
+
await executeUpdateCore(
|
|
2731
|
+
modelClass,
|
|
2732
|
+
ctx.input.key ?? {},
|
|
2733
|
+
ctx.input.changes ?? {},
|
|
2734
|
+
opts,
|
|
2735
|
+
(input, send) => runPersist(mw, { Update: input }, origin, cap, (it) => {
|
|
2736
|
+
const upd = it.Update;
|
|
2737
|
+
const old = cap.result?.oldItem;
|
|
2738
|
+
cap.newImage = { ...old ?? upd.Key, ...ctx.input.changes ?? {} };
|
|
2739
|
+
return send(upd);
|
|
2740
|
+
}),
|
|
2741
|
+
forceOldImage
|
|
1806
2742
|
);
|
|
2743
|
+
return change(cap, true);
|
|
1807
2744
|
}
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
2745
|
+
await executeDeleteCore(
|
|
2746
|
+
modelClass,
|
|
2747
|
+
ctx.input.key ?? {},
|
|
2748
|
+
opts,
|
|
2749
|
+
(input, send) => runPersist(
|
|
2750
|
+
mw,
|
|
2751
|
+
{ Delete: input },
|
|
2752
|
+
origin,
|
|
2753
|
+
cap,
|
|
2754
|
+
(it) => send(it.Delete)
|
|
2755
|
+
),
|
|
2756
|
+
forceOldImage
|
|
2757
|
+
);
|
|
2758
|
+
return change(cap, false);
|
|
1820
2759
|
}
|
|
1821
|
-
function
|
|
1822
|
-
const
|
|
1823
|
-
|
|
1824
|
-
|
|
2760
|
+
function change(cap, withNew) {
|
|
2761
|
+
const out = {};
|
|
2762
|
+
const oldImage = cap.result?.oldItem;
|
|
2763
|
+
if (oldImage !== void 0) out.oldImage = oldImage;
|
|
2764
|
+
if (withNew && cap.newImage !== void 0) {
|
|
2765
|
+
out.newImage = cap.newImage;
|
|
2766
|
+
}
|
|
2767
|
+
return out;
|
|
2768
|
+
}
|
|
2769
|
+
async function runPersist(mw, item, origin, cap, send) {
|
|
2770
|
+
const ctx = mw.persistCtx([item], [origin]);
|
|
2771
|
+
const result = await mw.runPersist(ctx, async (items) => send(items[0]));
|
|
2772
|
+
cap.result = result;
|
|
2773
|
+
return result;
|
|
1825
2774
|
}
|
|
1826
2775
|
|
|
1827
2776
|
// src/operations/update.ts
|
|
@@ -2035,6 +2984,17 @@ async function rederiveByReadModifyWrite(modelClass, entity, changes, options, p
|
|
|
2035
2984
|
return buildUpdateInput(modelClass, enriched, changes, guarded);
|
|
2036
2985
|
}
|
|
2037
2986
|
async function executeUpdate(modelClass, entity, changes, options) {
|
|
2987
|
+
const mw = writeRuntimeFor(options);
|
|
2988
|
+
if (mw) {
|
|
2989
|
+
await executeSingleWriteWithHooks(
|
|
2990
|
+
{ kind: "update", modelClass, key: entity, changes, options },
|
|
2991
|
+
mw
|
|
2992
|
+
);
|
|
2993
|
+
return;
|
|
2994
|
+
}
|
|
2995
|
+
await executeUpdateCore(modelClass, entity, changes, options);
|
|
2996
|
+
}
|
|
2997
|
+
async function executeUpdateCore(modelClass, entity, changes, options, persistVia, forceOldImage) {
|
|
2038
2998
|
let updateInput;
|
|
2039
2999
|
if (options?.rederive === "read-modify-write") {
|
|
2040
3000
|
const meta = MetadataRegistry.get(modelClass);
|
|
@@ -2066,11 +3026,12 @@ async function executeUpdate(modelClass, entity, changes, options) {
|
|
|
2066
3026
|
} else {
|
|
2067
3027
|
updateInput = buildUpdateInput(modelClass, entity, changes, options);
|
|
2068
3028
|
}
|
|
2069
|
-
const wantsOld = ChangeCaptureRegistry.wantsOldImage(modelClass);
|
|
2070
|
-
const
|
|
3029
|
+
const wantsOld = ChangeCaptureRegistry.wantsOldImage(modelClass) || forceOldImage === true;
|
|
3030
|
+
const send = (input) => ClientManager.getExecutor().update(input, {
|
|
2071
3031
|
...wantsOld ? { returnOldImage: true } : {},
|
|
2072
3032
|
...options?.retry !== void 0 ? { retry: options.retry } : {}
|
|
2073
3033
|
});
|
|
3034
|
+
const result = persistVia ? await persistVia(updateInput, send) : await send(updateInput);
|
|
2074
3035
|
if (ChangeCaptureRegistry.hasSubscribers()) {
|
|
2075
3036
|
const oldItem = result.oldItem;
|
|
2076
3037
|
const keys = { pk: String(updateInput.Key.PK), sk: String(updateInput.Key.SK ?? "") };
|
|
@@ -2090,70 +3051,6 @@ async function executeUpdate(modelClass, entity, changes, options) {
|
|
|
2090
3051
|
}
|
|
2091
3052
|
}
|
|
2092
3053
|
|
|
2093
|
-
// src/operations/delete.ts
|
|
2094
|
-
function buildDeleteInput(modelClass, keyObj, options) {
|
|
2095
|
-
const meta = MetadataRegistry.get(modelClass);
|
|
2096
|
-
if (!meta.primaryKey) {
|
|
2097
|
-
throw new Error(
|
|
2098
|
-
`No primary key defined for '${modelClass.name}'. Ensure the model has a static \`keys = key(...)\` definition.`
|
|
2099
|
-
);
|
|
2100
|
-
}
|
|
2101
|
-
const tableName = TableMapping.resolve(meta.tableName);
|
|
2102
|
-
const fieldMap = new Map(
|
|
2103
|
-
meta.fields.map((f) => [f.propertyName, f])
|
|
2104
|
-
);
|
|
2105
|
-
const keyInput = {};
|
|
2106
|
-
for (const fieldName of meta.primaryKey.inputFieldNames) {
|
|
2107
|
-
let value = keyObj[fieldName];
|
|
2108
|
-
const fieldMeta = fieldMap.get(fieldName);
|
|
2109
|
-
if (fieldMeta && value !== void 0) {
|
|
2110
|
-
value = serializeFieldValue(value, fieldMeta);
|
|
2111
|
-
}
|
|
2112
|
-
keyInput[fieldName] = value;
|
|
2113
|
-
}
|
|
2114
|
-
const { pk, sk } = resolveSegmentedKey(
|
|
2115
|
-
meta.primaryKey.segmented,
|
|
2116
|
-
keyInput,
|
|
2117
|
-
`${modelClass.name} primary key`
|
|
2118
|
-
);
|
|
2119
|
-
const deleteInput = {
|
|
2120
|
-
TableName: tableName,
|
|
2121
|
-
Key: {
|
|
2122
|
-
PK: pk,
|
|
2123
|
-
SK: sk ?? ""
|
|
2124
|
-
}
|
|
2125
|
-
};
|
|
2126
|
-
if (options?.condition) {
|
|
2127
|
-
const condResult = buildConditionExpression(options.condition);
|
|
2128
|
-
deleteInput.ConditionExpression = condResult.expression;
|
|
2129
|
-
if (Object.keys(condResult.names).length > 0) {
|
|
2130
|
-
deleteInput.ExpressionAttributeNames = condResult.names;
|
|
2131
|
-
}
|
|
2132
|
-
if (Object.keys(condResult.values).length > 0) {
|
|
2133
|
-
deleteInput.ExpressionAttributeValues = condResult.values;
|
|
2134
|
-
}
|
|
2135
|
-
}
|
|
2136
|
-
return deleteInput;
|
|
2137
|
-
}
|
|
2138
|
-
async function executeDelete(modelClass, keyObj, options) {
|
|
2139
|
-
const deleteInput = buildDeleteInput(modelClass, keyObj, options);
|
|
2140
|
-
const wantsOld = ChangeCaptureRegistry.wantsOldImage(modelClass);
|
|
2141
|
-
const result = await ClientManager.getExecutor().delete(deleteInput, {
|
|
2142
|
-
...wantsOld ? { returnOldImage: true } : {},
|
|
2143
|
-
...options?.retry !== void 0 ? { retry: options.retry } : {}
|
|
2144
|
-
});
|
|
2145
|
-
if (ChangeCaptureRegistry.hasSubscribers()) {
|
|
2146
|
-
const oldItem = result.oldItem;
|
|
2147
|
-
captureWrite({
|
|
2148
|
-
table: deleteInput.TableName,
|
|
2149
|
-
model: modelClass.name,
|
|
2150
|
-
op: "delete",
|
|
2151
|
-
keys: { pk: String(deleteInput.Key.PK), sk: String(deleteInput.Key.SK ?? "") },
|
|
2152
|
-
...oldItem ? { oldItem } : {}
|
|
2153
|
-
});
|
|
2154
|
-
}
|
|
2155
|
-
}
|
|
2156
|
-
|
|
2157
3054
|
// src/runtime/same-key-collapse.ts
|
|
2158
3055
|
function collapseSameKey(items, view) {
|
|
2159
3056
|
const groups = /* @__PURE__ */ new Map();
|
|
@@ -2216,12 +3113,21 @@ async function commitTransaction(items, options = {}) {
|
|
|
2216
3113
|
`Transaction exceeds DynamoDB limit of ${MAX_TRANSACT_ITEMS} items`
|
|
2217
3114
|
);
|
|
2218
3115
|
}
|
|
2219
|
-
|
|
2220
|
-
|
|
3116
|
+
const sendBatch = (items2) => ClientManager.getExecutor().transactWrite(
|
|
3117
|
+
items2,
|
|
2221
3118
|
options.retry !== void 0 ? { retry: options.retry } : void 0
|
|
2222
3119
|
);
|
|
2223
|
-
|
|
2224
|
-
|
|
3120
|
+
let committed = collapsed;
|
|
3121
|
+
if (options.middleware) {
|
|
3122
|
+
const { runtime, origins, transaction } = options.middleware;
|
|
3123
|
+
const persistCtx = runtime.persistCtx(collapsed, origins, transaction);
|
|
3124
|
+
await runtime.runPersist(persistCtx, async (items2) => sendBatch(items2));
|
|
3125
|
+
committed = persistCtx.items;
|
|
3126
|
+
} else {
|
|
3127
|
+
await sendBatch(collapsed);
|
|
3128
|
+
}
|
|
3129
|
+
captureTransactItems(committed, options.modelBySignature ?? EMPTY_MODEL_MAP);
|
|
3130
|
+
return committed;
|
|
2225
3131
|
}
|
|
2226
3132
|
var EMPTY_MODEL_MAP = /* @__PURE__ */ new Map();
|
|
2227
3133
|
function captureTransactItems(items, modelBySignature) {
|
|
@@ -2338,6 +3244,14 @@ function resolveModelClass(model) {
|
|
|
2338
3244
|
var TransactionContext = class {
|
|
2339
3245
|
items = [];
|
|
2340
3246
|
captureMeta = [];
|
|
3247
|
+
/**
|
|
3248
|
+
* The logical write ops, parallel to {@link items} but EXCLUDING `conditionCheck`
|
|
3249
|
+
* entries (those are read-only assertions, recorded only in `items`). Used by the
|
|
3250
|
+
* write-hook path (#139) to run W1 on each logical op and recompose the batch.
|
|
3251
|
+
* A `null` entry marks an `items` slot that has no logical op (a ConditionCheck),
|
|
3252
|
+
* so the eager and logical arrays can be re-aligned at commit.
|
|
3253
|
+
*/
|
|
3254
|
+
logicalOps = [];
|
|
2341
3255
|
get itemCount() {
|
|
2342
3256
|
return this.items.length;
|
|
2343
3257
|
}
|
|
@@ -2346,6 +3260,7 @@ var TransactionContext = class {
|
|
|
2346
3260
|
const modelClass = resolveModelClass(model);
|
|
2347
3261
|
const putInput = buildPutInput(modelClass, item, options);
|
|
2348
3262
|
this.items.push({ Put: putInput });
|
|
3263
|
+
this.logicalOps.push({ kind: "put", modelClass, item, options });
|
|
2349
3264
|
this.captureMeta.push({
|
|
2350
3265
|
modelName: modelClass.name,
|
|
2351
3266
|
op: "put",
|
|
@@ -2359,6 +3274,7 @@ var TransactionContext = class {
|
|
|
2359
3274
|
const modelClass = resolveModelClass(model);
|
|
2360
3275
|
const updateInput = buildUpdateInput(modelClass, entity, changes, options);
|
|
2361
3276
|
this.items.push({ Update: updateInput });
|
|
3277
|
+
this.logicalOps.push({ kind: "update", modelClass, key: entity, changes, options });
|
|
2362
3278
|
this.captureMeta.push({
|
|
2363
3279
|
modelName: modelClass.name,
|
|
2364
3280
|
op: "update",
|
|
@@ -2371,6 +3287,7 @@ var TransactionContext = class {
|
|
|
2371
3287
|
const modelClass = resolveModelClass(model);
|
|
2372
3288
|
const deleteInput = buildDeleteInput(modelClass, key2, options);
|
|
2373
3289
|
this.items.push({ Delete: deleteInput });
|
|
3290
|
+
this.logicalOps.push({ kind: "delete", modelClass, key: key2, options });
|
|
2374
3291
|
this.captureMeta.push({
|
|
2375
3292
|
modelName: modelClass.name,
|
|
2376
3293
|
op: "delete",
|
|
@@ -2412,11 +3329,16 @@ var TransactionContext = class {
|
|
|
2412
3329
|
check.ExpressionAttributeValues = cond2.values;
|
|
2413
3330
|
}
|
|
2414
3331
|
this.items.push({ ConditionCheck: check });
|
|
3332
|
+
this.logicalOps.push(null);
|
|
2415
3333
|
}
|
|
2416
3334
|
/** @internal */
|
|
2417
3335
|
getTransactItems() {
|
|
2418
3336
|
return this.items;
|
|
2419
3337
|
}
|
|
3338
|
+
/** @internal — the recorded logical write ops (issue #139 W1), `null` per ConditionCheck slot. */
|
|
3339
|
+
getLogicalOps() {
|
|
3340
|
+
return this.logicalOps;
|
|
3341
|
+
}
|
|
2420
3342
|
/** @internal — capture descriptors for the write-capture seam (issue #72). */
|
|
2421
3343
|
getCaptureMeta() {
|
|
2422
3344
|
return this.captureMeta;
|
|
@@ -2429,13 +3351,47 @@ var TransactionContext = class {
|
|
|
2429
3351
|
}
|
|
2430
3352
|
}
|
|
2431
3353
|
};
|
|
2432
|
-
async function executeTransaction(fn) {
|
|
3354
|
+
async function executeTransaction(fn, options) {
|
|
2433
3355
|
const tx = new TransactionContext();
|
|
2434
3356
|
await fn(tx);
|
|
2435
|
-
|
|
3357
|
+
let transactItems = tx.getTransactItems();
|
|
2436
3358
|
if (transactItems.length === 0) {
|
|
2437
3359
|
return;
|
|
2438
3360
|
}
|
|
3361
|
+
const writeMw = buildWriteRuntime(options?.context);
|
|
3362
|
+
if (writeMw.active) {
|
|
3363
|
+
const txId = { id: /* @__PURE__ */ Symbol("graphddb.transaction") };
|
|
3364
|
+
const logical = tx.getLogicalOps();
|
|
3365
|
+
const items = [...transactItems];
|
|
3366
|
+
const origins = [];
|
|
3367
|
+
const writeCtxs = [];
|
|
3368
|
+
const modelBySignature2 = /* @__PURE__ */ new Map();
|
|
3369
|
+
for (let i = 0; i < logical.length; i++) {
|
|
3370
|
+
const op = logical[i];
|
|
3371
|
+
if (op === null) continue;
|
|
3372
|
+
const input = {};
|
|
3373
|
+
if (op.item !== void 0) input.item = { ...op.item };
|
|
3374
|
+
if (op.key !== void 0) input.key = { ...op.key };
|
|
3375
|
+
if (op.changes !== void 0) input.changes = { ...op.changes };
|
|
3376
|
+
if (op.options !== void 0) input.options = { ...op.options };
|
|
3377
|
+
const ctx = writeMw.writeCtx(op.kind, ctxModelFor(op.modelClass), input, txId);
|
|
3378
|
+
await writeMw.runWriteBefore(ctx);
|
|
3379
|
+
const built = buildLogicalItem(op.modelClass, ctx);
|
|
3380
|
+
items[i] = built;
|
|
3381
|
+
modelBySignature2.set(execItemKeySignature(built), op.modelClass.name);
|
|
3382
|
+
origins.push({ model: ctx.model, kind: ctx.kind });
|
|
3383
|
+
writeCtxs.push(ctx);
|
|
3384
|
+
}
|
|
3385
|
+
transactItems = items;
|
|
3386
|
+
await commitTransaction(transactItems, {
|
|
3387
|
+
modelBySignature: modelBySignature2,
|
|
3388
|
+
middleware: { runtime: writeMw, origins, transaction: txId }
|
|
3389
|
+
});
|
|
3390
|
+
for (let j = writeCtxs.length - 1; j >= 0; j--) {
|
|
3391
|
+
await writeMw.runWriteAfter(writeCtxs[j], {});
|
|
3392
|
+
}
|
|
3393
|
+
return;
|
|
3394
|
+
}
|
|
2439
3395
|
const modelBySignature = /* @__PURE__ */ new Map();
|
|
2440
3396
|
for (const meta of tx.getCaptureMeta()) {
|
|
2441
3397
|
if (meta.modelName) {
|
|
@@ -2449,6 +3405,18 @@ async function executeTransaction(fn) {
|
|
|
2449
3405
|
modelBySignature
|
|
2450
3406
|
});
|
|
2451
3407
|
}
|
|
3408
|
+
function buildLogicalItem(modelClass, ctx) {
|
|
3409
|
+
const opts = ctx.input.options;
|
|
3410
|
+
if (ctx.kind === "put") {
|
|
3411
|
+
return { Put: buildPutInput(modelClass, ctx.input.item ?? {}, opts) };
|
|
3412
|
+
}
|
|
3413
|
+
if (ctx.kind === "update") {
|
|
3414
|
+
return {
|
|
3415
|
+
Update: buildUpdateInput(modelClass, ctx.input.key ?? {}, ctx.input.changes ?? {}, opts)
|
|
3416
|
+
};
|
|
3417
|
+
}
|
|
3418
|
+
return { Delete: buildDeleteInput(modelClass, ctx.input.key ?? {}, opts) };
|
|
3419
|
+
}
|
|
2452
3420
|
|
|
2453
3421
|
export {
|
|
2454
3422
|
isColumn,
|
|
@@ -2475,6 +3443,7 @@ export {
|
|
|
2475
3443
|
isParam,
|
|
2476
3444
|
BATCH_GET_MAX_KEYS,
|
|
2477
3445
|
BATCH_WRITE_MAX_ITEMS,
|
|
3446
|
+
chunkArray,
|
|
2478
3447
|
DynamoExecutor,
|
|
2479
3448
|
DEFAULT_MAX_ATTEMPTS,
|
|
2480
3449
|
DEFAULT_RETRY_POLICY,
|
|
@@ -2492,14 +3461,18 @@ export {
|
|
|
2492
3461
|
serializeFieldValue,
|
|
2493
3462
|
ChangeCaptureRegistry,
|
|
2494
3463
|
captureWrite,
|
|
2495
|
-
|
|
2496
|
-
|
|
3464
|
+
NO_MIDDLEWARE,
|
|
3465
|
+
buildReadRuntime,
|
|
3466
|
+
buildWriteRuntime,
|
|
3467
|
+
ctxModelFor,
|
|
2497
3468
|
buildUpdateExpression,
|
|
2498
3469
|
attachHiddenKey,
|
|
2499
3470
|
buildUpdateInput,
|
|
2500
3471
|
executeUpdate,
|
|
2501
3472
|
buildDeleteInput,
|
|
2502
3473
|
executeDelete,
|
|
3474
|
+
buildPutInput,
|
|
3475
|
+
executePut,
|
|
2503
3476
|
MAX_TRANSACT_ITEMS,
|
|
2504
3477
|
collapseSameKeyItems,
|
|
2505
3478
|
commitTransaction,
|