graphddb 0.2.5 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -1
- package/dist/chunk-PNIZS37E.js +916 -0
- package/dist/{chunk-SGYBE2OV.js → chunk-VST3WOK3.js} +597 -113
- package/dist/{chunk-4TYK3AV6.js → chunk-YIXXTGZ6.js} +1369 -15
- package/dist/cli.js +2 -2
- package/dist/index.d.ts +587 -75
- package/dist/index.js +535 -16
- package/dist/testing/index.d.ts +1 -1
- package/dist/testing/index.js +2 -2
- package/dist/{types--tdHK3vi.d.ts → types-DuJ08bgc.d.ts} +1542 -63
- package/package.json +1 -1
- package/dist/chunk-ITHQ2EDH.js +0 -461
|
@@ -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)) {
|
|
@@ -1858,14 +2315,14 @@ var MiddlewareRuntime = class {
|
|
|
1858
2315
|
* is the host's responsibility — a hook recovering a `query` should return an
|
|
1859
2316
|
* item / `null`, one recovering a `list` a `{ items, cursor }`.
|
|
1860
2317
|
*/
|
|
1861
|
-
async runRequestError(ctx,
|
|
2318
|
+
async runRequestError(ctx, err3) {
|
|
1862
2319
|
for (let i = this.chain.length - 1; i >= 0; i--) {
|
|
1863
2320
|
const onError = this.chain[i].read?.onError;
|
|
1864
2321
|
if (!onError) continue;
|
|
1865
|
-
const recovered = await onError(ctx,
|
|
2322
|
+
const recovered = await onError(ctx, err3);
|
|
1866
2323
|
if (recovered !== void 0) return recovered;
|
|
1867
2324
|
}
|
|
1868
|
-
throw
|
|
2325
|
+
throw err3;
|
|
1869
2326
|
}
|
|
1870
2327
|
/**
|
|
1871
2328
|
* Drive ONE physical op (root read or any fan-out fetch) through R2 → send →
|
|
@@ -1905,14 +2362,14 @@ var MiddlewareRuntime = class {
|
|
|
1905
2362
|
if (afterFetch) items = await afterFetch(ctx, items);
|
|
1906
2363
|
}
|
|
1907
2364
|
return items;
|
|
1908
|
-
} catch (
|
|
2365
|
+
} catch (err3) {
|
|
1909
2366
|
for (let i = this.chain.length - 1; i >= 0; i--) {
|
|
1910
2367
|
const onError = this.chain[i].read?.op?.onError;
|
|
1911
2368
|
if (!onError) continue;
|
|
1912
|
-
const recovered = await onError(ctx,
|
|
2369
|
+
const recovered = await onError(ctx, err3);
|
|
1913
2370
|
if (recovered !== void 0) return recovered;
|
|
1914
2371
|
}
|
|
1915
|
-
throw
|
|
2372
|
+
throw err3;
|
|
1916
2373
|
}
|
|
1917
2374
|
}
|
|
1918
2375
|
};
|
|
@@ -1976,14 +2433,14 @@ var WriteRuntime = class {
|
|
|
1976
2433
|
* short-circuits the chain); the value becomes the write's resolved value.
|
|
1977
2434
|
* Otherwise the original error rethrows (symmetric with the read `onError`).
|
|
1978
2435
|
*/
|
|
1979
|
-
async runWriteError(ctx,
|
|
2436
|
+
async runWriteError(ctx, err3) {
|
|
1980
2437
|
for (let i = this.chain.length - 1; i >= 0; i--) {
|
|
1981
2438
|
const onError = this.chain[i].write?.onError;
|
|
1982
2439
|
if (!onError) continue;
|
|
1983
|
-
const recovered = await onError(ctx,
|
|
2440
|
+
const recovered = await onError(ctx, err3);
|
|
1984
2441
|
if (recovered !== void 0) return recovered;
|
|
1985
2442
|
}
|
|
1986
|
-
throw
|
|
2443
|
+
throw err3;
|
|
1987
2444
|
}
|
|
1988
2445
|
/** Build a W3/W4/W5 persist context (pure — no hooks run). */
|
|
1989
2446
|
persistCtx(items, origins, transaction) {
|
|
@@ -2012,20 +2469,20 @@ var WriteRuntime = class {
|
|
|
2012
2469
|
if (before) await before(ctx);
|
|
2013
2470
|
}
|
|
2014
2471
|
results = await send(ctx.items);
|
|
2015
|
-
} catch (
|
|
2472
|
+
} catch (err3) {
|
|
2016
2473
|
let recovered;
|
|
2017
2474
|
let didRecover = false;
|
|
2018
2475
|
for (let i = this.chain.length - 1; i >= 0; i--) {
|
|
2019
2476
|
const onError = this.chain[i].write?.persist?.onError;
|
|
2020
2477
|
if (!onError) continue;
|
|
2021
|
-
const r = await onError(ctx,
|
|
2478
|
+
const r = await onError(ctx, err3);
|
|
2022
2479
|
if (r !== void 0) {
|
|
2023
2480
|
recovered = r;
|
|
2024
2481
|
didRecover = true;
|
|
2025
2482
|
break;
|
|
2026
2483
|
}
|
|
2027
2484
|
}
|
|
2028
|
-
if (!didRecover) throw
|
|
2485
|
+
if (!didRecover) throw err3;
|
|
2029
2486
|
results = recovered;
|
|
2030
2487
|
}
|
|
2031
2488
|
for (let i = this.chain.length - 1; i >= 0; i--) {
|
|
@@ -2246,8 +2703,8 @@ async function executeSingleWriteWithHooks(req, mw) {
|
|
|
2246
2703
|
await mw.runWriteBefore(ctx);
|
|
2247
2704
|
const change2 = await persistRewrittenWrite(req.modelClass, ctx, mw);
|
|
2248
2705
|
await mw.runWriteAfter(ctx, change2);
|
|
2249
|
-
} catch (
|
|
2250
|
-
await mw.runWriteError(ctx,
|
|
2706
|
+
} catch (err3) {
|
|
2707
|
+
await mw.runWriteError(ctx, err3);
|
|
2251
2708
|
}
|
|
2252
2709
|
}
|
|
2253
2710
|
async function persistRewrittenWrite(modelClass, ctx, mw) {
|
|
@@ -2961,6 +3418,882 @@ function buildLogicalItem(modelClass, ctx) {
|
|
|
2961
3418
|
return { Delete: buildDeleteInput(modelClass, ctx.input.key ?? {}, opts) };
|
|
2962
3419
|
}
|
|
2963
3420
|
|
|
3421
|
+
// src/define/entity-writes.ts
|
|
3422
|
+
var MAINTAIN_TRIGGER_RE = /^[^.\s$[\]*]+\.(?:created|updated|removed)$/;
|
|
3423
|
+
function maintainTrigger(value) {
|
|
3424
|
+
if (typeof value !== "string" || !MAINTAIN_TRIGGER_RE.test(value)) {
|
|
3425
|
+
throw new Error(
|
|
3426
|
+
`maintainTrigger: a maintenance trigger must be an \`Entity.event\` string where event is one of \`created\` / \`updated\` / \`removed\` (e.g. \`"Post.created"\`), but received ${JSON.stringify(value)}.`
|
|
3427
|
+
);
|
|
3428
|
+
}
|
|
3429
|
+
return value;
|
|
3430
|
+
}
|
|
3431
|
+
function isMaintainTrigger(value) {
|
|
3432
|
+
return typeof value === "string" && MAINTAIN_TRIGGER_RE.test(value);
|
|
3433
|
+
}
|
|
3434
|
+
function makeProjectionTransform(path, op, args) {
|
|
3435
|
+
if (op === "preview") {
|
|
3436
|
+
const [n] = args;
|
|
3437
|
+
if (typeof n !== "number" || !Number.isInteger(n) || n <= 0) {
|
|
3438
|
+
throw new Error(
|
|
3439
|
+
`transform: the \`preview\` op requires a positive integer length bound (e.g. \`preview('$.body', 200)\`), but received ${JSON.stringify(n)}.`
|
|
3440
|
+
);
|
|
3441
|
+
}
|
|
3442
|
+
return { kind: "transform", path, op, args: [n] };
|
|
3443
|
+
}
|
|
3444
|
+
if (op === "identity") {
|
|
3445
|
+
if (args.length > 0) {
|
|
3446
|
+
throw new Error(
|
|
3447
|
+
`transform: the \`identity\` op takes no arguments, but received ${JSON.stringify(args)}.`
|
|
3448
|
+
);
|
|
3449
|
+
}
|
|
3450
|
+
return { kind: "transform", path, op, args: [] };
|
|
3451
|
+
}
|
|
3452
|
+
throw new Error(
|
|
3453
|
+
`transform: unknown projection op ${JSON.stringify(op)} (expected \`identity\` or \`preview\`).`
|
|
3454
|
+
);
|
|
3455
|
+
}
|
|
3456
|
+
function preview(path, n) {
|
|
3457
|
+
return makeProjectionTransform(path, "preview", [n]);
|
|
3458
|
+
}
|
|
3459
|
+
function identity(path) {
|
|
3460
|
+
return makeProjectionTransform(path, "identity", []);
|
|
3461
|
+
}
|
|
3462
|
+
var LIFECYCLE_CONTRACT_MARKER = /* @__PURE__ */ Symbol(
|
|
3463
|
+
"graphddb:lifecycleContract"
|
|
3464
|
+
);
|
|
3465
|
+
function isLifecycleContract(value) {
|
|
3466
|
+
return typeof value === "object" && value !== null && value[LIFECYCLE_CONTRACT_MARKER] === true;
|
|
3467
|
+
}
|
|
3468
|
+
var ENTITY_WRITES_MARKER = /* @__PURE__ */ Symbol("graphddb:entityWrites");
|
|
3469
|
+
function isEntityWritesDefinition(value) {
|
|
3470
|
+
return typeof value === "object" && value !== null && value[ENTITY_WRITES_MARKER] === true;
|
|
3471
|
+
}
|
|
3472
|
+
function freezeEffects(effects) {
|
|
3473
|
+
const out = {};
|
|
3474
|
+
if (effects.requires !== void 0) out.requires = effects.requires;
|
|
3475
|
+
if (effects.unique !== void 0) out.unique = effects.unique;
|
|
3476
|
+
if (effects.edges !== void 0) out.edges = effects.edges;
|
|
3477
|
+
if (effects.derive !== void 0) out.derive = effects.derive;
|
|
3478
|
+
if (effects.emits !== void 0) out.emits = effects.emits;
|
|
3479
|
+
if (effects.idempotency !== void 0) out.idempotency = effects.idempotency;
|
|
3480
|
+
return out;
|
|
3481
|
+
}
|
|
3482
|
+
var recorder = {
|
|
3483
|
+
lifecycle(effects = {}) {
|
|
3484
|
+
return {
|
|
3485
|
+
[LIFECYCLE_CONTRACT_MARKER]: true,
|
|
3486
|
+
effects: freezeEffects(effects)
|
|
3487
|
+
};
|
|
3488
|
+
},
|
|
3489
|
+
exists(targetFactory, keys) {
|
|
3490
|
+
return { kind: "requires", targetFactory, keys };
|
|
3491
|
+
},
|
|
3492
|
+
unique(spec) {
|
|
3493
|
+
return { kind: "unique", name: spec.name, scope: spec.scope, fields: spec.fields };
|
|
3494
|
+
},
|
|
3495
|
+
putEdge(targetFactory, relationProperty) {
|
|
3496
|
+
return { kind: "putEdge", targetFactory, relationProperty };
|
|
3497
|
+
},
|
|
3498
|
+
deleteEdge(targetFactory, relationProperty) {
|
|
3499
|
+
return { kind: "deleteEdge", targetFactory, relationProperty };
|
|
3500
|
+
},
|
|
3501
|
+
increment(targetFactory, keys, attribute, amount) {
|
|
3502
|
+
return { kind: "derive", targetFactory, keys, attribute, amount };
|
|
3503
|
+
},
|
|
3504
|
+
transform(path, op, ...args) {
|
|
3505
|
+
return makeProjectionTransform(path, op, args);
|
|
3506
|
+
},
|
|
3507
|
+
event(name, payload) {
|
|
3508
|
+
return { kind: "event", name, payload };
|
|
3509
|
+
},
|
|
3510
|
+
idempotentBy(token) {
|
|
3511
|
+
return { kind: "idempotency", token };
|
|
3512
|
+
}
|
|
3513
|
+
};
|
|
3514
|
+
function entityWrites(builder) {
|
|
3515
|
+
const shape = builder(recorder);
|
|
3516
|
+
for (const phase of ["create", "update", "remove"]) {
|
|
3517
|
+
const contract = shape[phase];
|
|
3518
|
+
if (contract !== void 0 && !isLifecycleContract(contract)) {
|
|
3519
|
+
throw new Error(
|
|
3520
|
+
`entityWrites: the '${phase}' lifecycle must be built with \`w.lifecycle({...})\` (it carries the \xA72 effect arrays), but received ${JSON.stringify(contract)}.`
|
|
3521
|
+
);
|
|
3522
|
+
}
|
|
3523
|
+
}
|
|
3524
|
+
if (shape.create === void 0 && shape.update === void 0 && shape.remove === void 0) {
|
|
3525
|
+
throw new Error(
|
|
3526
|
+
"entityWrites(...) must declare at least one lifecycle (`create` / `update` / `remove`); an empty save contract declares nothing."
|
|
3527
|
+
);
|
|
3528
|
+
}
|
|
3529
|
+
return {
|
|
3530
|
+
[ENTITY_WRITES_MARKER]: true,
|
|
3531
|
+
...shape.create !== void 0 ? { create: shape.create } : {},
|
|
3532
|
+
...shape.update !== void 0 ? { update: shape.update } : {},
|
|
3533
|
+
...shape.remove !== void 0 ? { remove: shape.remove } : {}
|
|
3534
|
+
};
|
|
3535
|
+
}
|
|
3536
|
+
function getEntityWrites(modelClass) {
|
|
3537
|
+
for (const name of Object.getOwnPropertyNames(modelClass)) {
|
|
3538
|
+
let value;
|
|
3539
|
+
try {
|
|
3540
|
+
value = modelClass[name];
|
|
3541
|
+
} catch {
|
|
3542
|
+
continue;
|
|
3543
|
+
}
|
|
3544
|
+
if (isEntityWritesDefinition(value)) return value;
|
|
3545
|
+
}
|
|
3546
|
+
return void 0;
|
|
3547
|
+
}
|
|
3548
|
+
function lifecyclePhaseForIntent(intent) {
|
|
3549
|
+
return intent;
|
|
3550
|
+
}
|
|
3551
|
+
|
|
3552
|
+
// src/define/view.ts
|
|
3553
|
+
function whenMember(field, op, value) {
|
|
3554
|
+
const path = field.startsWith("$.input.") || field.startsWith("$.entity.") ? field : `$.entity.${field}`;
|
|
3555
|
+
const needsValue = op === "eq" || op === "ne";
|
|
3556
|
+
if (needsValue && value === void 0) {
|
|
3557
|
+
throw new Error(
|
|
3558
|
+
`whenMember('${field}', '${op}', \u2026): the '${op}' op requires a comparand value (e.g. \`whenMember('${field}', '${op}', 'active')\`).`
|
|
3559
|
+
);
|
|
3560
|
+
}
|
|
3561
|
+
return needsValue ? { path, op, value } : { path, op };
|
|
3562
|
+
}
|
|
3563
|
+
var VIEW_DEFINITION_MARKER = /* @__PURE__ */ Symbol("graphddb:viewDefinition");
|
|
3564
|
+
var ViewRegistry = class {
|
|
3565
|
+
static views = /* @__PURE__ */ new Map();
|
|
3566
|
+
static gen = 0;
|
|
3567
|
+
/** Register (or replace, by view class) a declared view. Bumps the generation. */
|
|
3568
|
+
static register(def) {
|
|
3569
|
+
this.views.set(def.viewClass, def);
|
|
3570
|
+
this.gen += 1;
|
|
3571
|
+
}
|
|
3572
|
+
/** Every registered view, in declaration order. */
|
|
3573
|
+
static getAll() {
|
|
3574
|
+
return [...this.views.values()];
|
|
3575
|
+
}
|
|
3576
|
+
/** The monotonic generation counter (changes whenever the set of views changes). */
|
|
3577
|
+
static get generation() {
|
|
3578
|
+
return this.gen;
|
|
3579
|
+
}
|
|
3580
|
+
/** Forget every registered view (test reset). Bumps the generation. */
|
|
3581
|
+
static clear() {
|
|
3582
|
+
this.views.clear();
|
|
3583
|
+
this.gen += 1;
|
|
3584
|
+
}
|
|
3585
|
+
};
|
|
3586
|
+
function isViewDefinition(value) {
|
|
3587
|
+
return typeof value === "object" && value !== null && value[VIEW_DEFINITION_MARKER] === true;
|
|
3588
|
+
}
|
|
3589
|
+
function resolveSourceClass(factory) {
|
|
3590
|
+
const resolved = factory();
|
|
3591
|
+
try {
|
|
3592
|
+
return resolveModelClass(resolved);
|
|
3593
|
+
} catch {
|
|
3594
|
+
return resolved;
|
|
3595
|
+
}
|
|
3596
|
+
}
|
|
3597
|
+
function isPathRooted(value) {
|
|
3598
|
+
return value.startsWith("$.input.") || value.startsWith("$.entity.");
|
|
3599
|
+
}
|
|
3600
|
+
function buildProjection(viewName, fields) {
|
|
3601
|
+
const entries = fields ? Object.entries(fields) : [];
|
|
3602
|
+
if (entries.length === 0) {
|
|
3603
|
+
throw new Error(
|
|
3604
|
+
`defineView('${viewName}'): a source projects no \`fields\` (and the view declares no default \`fields\`). A materialized view with no captured attributes projects nothing \u2014 declare \`fields\` (e.g. \`{ postId: 'postId', textPreview: preview('$.entity.body', 120) }\`).`
|
|
3605
|
+
);
|
|
3606
|
+
}
|
|
3607
|
+
const out = {};
|
|
3608
|
+
for (const [attr, value] of entries) {
|
|
3609
|
+
if (typeof value === "string") {
|
|
3610
|
+
const path = isPathRooted(value) ? value : `$.entity.${value}`;
|
|
3611
|
+
out[attr] = identity(path);
|
|
3612
|
+
} else {
|
|
3613
|
+
out[attr] = value;
|
|
3614
|
+
}
|
|
3615
|
+
}
|
|
3616
|
+
return out;
|
|
3617
|
+
}
|
|
3618
|
+
function validateTrigger(viewName, raw) {
|
|
3619
|
+
if (!isMaintainTrigger(raw)) {
|
|
3620
|
+
throw new Error(
|
|
3621
|
+
`defineView('${viewName}'): maintenance trigger ${JSON.stringify(raw)} is not a well-formed \`Entity.event\` trigger (event must be one of 'created' / 'updated' / 'removed'). Fix the source's \`maintainedOn\`.`
|
|
3622
|
+
);
|
|
3623
|
+
}
|
|
3624
|
+
return raw;
|
|
3625
|
+
}
|
|
3626
|
+
function defineView(view, input) {
|
|
3627
|
+
const viewClass = resolveSourceClass((() => view));
|
|
3628
|
+
const name = viewClass.name;
|
|
3629
|
+
if (typeof name !== "string" || name.length === 0) {
|
|
3630
|
+
throw new Error("defineView(view, input): the view model must be a named class.");
|
|
3631
|
+
}
|
|
3632
|
+
if (input.pattern !== void 0 && input.pattern !== "materializedView" && input.pattern !== "sparseView") {
|
|
3633
|
+
throw new Error(
|
|
3634
|
+
`defineView('${name}'): \`pattern\` must be 'materializedView' or 'sparseView' (got ${JSON.stringify(input.pattern)}).`
|
|
3635
|
+
);
|
|
3636
|
+
}
|
|
3637
|
+
const pattern = input.pattern ?? "materializedView";
|
|
3638
|
+
const sources = Array.isArray(input.source) ? input.source : [input.source];
|
|
3639
|
+
if (sources.length === 0) {
|
|
3640
|
+
throw new Error(
|
|
3641
|
+
`defineView('${name}'): at least one \`source\` is required (a view with no source is maintained by nothing).`
|
|
3642
|
+
);
|
|
3643
|
+
}
|
|
3644
|
+
const slices = [];
|
|
3645
|
+
for (const src of sources) {
|
|
3646
|
+
const keys = src.keys ?? input.keys;
|
|
3647
|
+
if (keys === void 0 || Object.keys(keys).length === 0) {
|
|
3648
|
+
throw new Error(
|
|
3649
|
+
`defineView('${name}'): a source declares no \`keys\` and the view has no default \`keys\`. The view-row key binding (view-row key field \u2192 \`$.entity.<sourceField>\`) is required to resolve which view row a source event maintains.`
|
|
3650
|
+
);
|
|
3651
|
+
}
|
|
3652
|
+
if (!src.maintainedOn || src.maintainedOn.length === 0) {
|
|
3653
|
+
throw new Error(
|
|
3654
|
+
`defineView('${name}'): a source declares no \`maintainedOn\` triggers. Each source must name the lifecycle events that maintain the view (e.g. \`['Post.created', 'Post.removed']\`).`
|
|
3655
|
+
);
|
|
3656
|
+
}
|
|
3657
|
+
const maintainedOn = src.maintainedOn.map((t) => validateTrigger(name, t));
|
|
3658
|
+
const project = buildProjection(name, src.fields ?? input.fields);
|
|
3659
|
+
const collection = src.collection ? {
|
|
3660
|
+
field: src.collection.field,
|
|
3661
|
+
...src.collection.maxItems !== void 0 ? { maxItems: src.collection.maxItems } : {},
|
|
3662
|
+
...src.collection.orderBy !== void 0 ? {
|
|
3663
|
+
orderBy: src.collection.orderBy,
|
|
3664
|
+
orderDir: src.collection.order ?? "DESC"
|
|
3665
|
+
} : {}
|
|
3666
|
+
} : void 0;
|
|
3667
|
+
const predicate = src.when;
|
|
3668
|
+
if (predicate && collection) {
|
|
3669
|
+
throw new Error(
|
|
3670
|
+
`defineView('${name}'): a source declares both \`when\` (a sparse-view membership predicate) and \`collection\`. They are mutually exclusive \u2014 a membership row appears/disappears as a whole; a collection slice holds a maintained list.`
|
|
3671
|
+
);
|
|
3672
|
+
}
|
|
3673
|
+
if (pattern === "sparseView" && !predicate) {
|
|
3674
|
+
throw new Error(
|
|
3675
|
+
`defineView('${name}'): \`pattern: 'sparseView'\` requires every source to declare a \`when\` membership predicate (e.g. \`whenMember('status', 'eq', 'active')\`) \u2014 the predicate is what decides PUT (holds) vs DELETE (flips false) of the view row.`
|
|
3676
|
+
);
|
|
3677
|
+
}
|
|
3678
|
+
if (predicate && pattern !== "sparseView") {
|
|
3679
|
+
throw new Error(
|
|
3680
|
+
`defineView('${name}'): a source declares a \`when\` membership predicate but the view \`pattern\` is '${pattern}'. Declare \`pattern: 'sparseView'\` to maintain a predicate-gated put/delete row.`
|
|
3681
|
+
);
|
|
3682
|
+
}
|
|
3683
|
+
slices.push({
|
|
3684
|
+
sourceClass: resolveSourceClass(src.entity),
|
|
3685
|
+
maintainedOn,
|
|
3686
|
+
keys,
|
|
3687
|
+
project,
|
|
3688
|
+
...collection ? { collection } : {},
|
|
3689
|
+
...predicate ? { predicate } : {}
|
|
3690
|
+
});
|
|
3691
|
+
}
|
|
3692
|
+
const def = {
|
|
3693
|
+
[VIEW_DEFINITION_MARKER]: true,
|
|
3694
|
+
name,
|
|
3695
|
+
viewClass,
|
|
3696
|
+
pattern,
|
|
3697
|
+
slices,
|
|
3698
|
+
...input.write?.updateMode !== void 0 ? { updateMode: input.write.updateMode } : {},
|
|
3699
|
+
...input.write?.consistency !== void 0 ? { consistency: input.write.consistency } : {}
|
|
3700
|
+
};
|
|
3701
|
+
ViewRegistry.register(def);
|
|
3702
|
+
return def;
|
|
3703
|
+
}
|
|
3704
|
+
function defineViews(definitions) {
|
|
3705
|
+
for (const [name, def] of Object.entries(definitions)) {
|
|
3706
|
+
if (!isViewDefinition(def)) {
|
|
3707
|
+
throw new Error(
|
|
3708
|
+
`defineViews: '${name}' is not a view definition. Use defineView(name, { pattern: 'materializedView', source, keys, fields }) to build entries.`
|
|
3709
|
+
);
|
|
3710
|
+
}
|
|
3711
|
+
}
|
|
3712
|
+
return definitions;
|
|
3713
|
+
}
|
|
3714
|
+
function defineVersioned(latest, history, input) {
|
|
3715
|
+
if (input.pattern !== void 0 && input.pattern !== "versioned") {
|
|
3716
|
+
throw new Error(
|
|
3717
|
+
`defineVersioned: \`pattern\` must be 'versioned' (got ${JSON.stringify(input.pattern)}).`
|
|
3718
|
+
);
|
|
3719
|
+
}
|
|
3720
|
+
const latestClass = resolveSourceClass((() => latest));
|
|
3721
|
+
const historyClass = resolveSourceClass((() => history));
|
|
3722
|
+
if (latestClass === historyClass) {
|
|
3723
|
+
throw new Error(
|
|
3724
|
+
`defineVersioned: the latest-pointer and history models must be DISTINCT (both are '${latestClass.name}'). The latest row is overwritten per revision and the history row is appended per version, so they live on different rows.`
|
|
3725
|
+
);
|
|
3726
|
+
}
|
|
3727
|
+
const latestKeyFields = new Set(Object.keys(input.latestKeys));
|
|
3728
|
+
const historyKeyFields = Object.keys(input.historyKeys);
|
|
3729
|
+
const discriminators = historyKeyFields.filter((f) => !latestKeyFields.has(f));
|
|
3730
|
+
if (discriminators.length === 0) {
|
|
3731
|
+
throw new Error(
|
|
3732
|
+
`defineVersioned: \`historyKeys\` [${historyKeyFields.map((f) => `'${f}'`).join(", ")}] carries no version discriminator beyond \`latestKeys\` [${[...latestKeyFields].map((f) => `'${f}'`).join(", ")}]. The history row must be keyed by a per-version field (e.g. add \`version: '$.entity.version'\`) so each revision is a NEW row; otherwise every revision overwrites one row (a latest pointer, not an append-only history).`
|
|
3733
|
+
);
|
|
3734
|
+
}
|
|
3735
|
+
const latestView = defineView(latest, {
|
|
3736
|
+
pattern: "materializedView",
|
|
3737
|
+
source: { entity: input.source, maintainedOn: input.maintainedOn, keys: input.latestKeys },
|
|
3738
|
+
fields: input.fields,
|
|
3739
|
+
...input.write ? { write: input.write } : {}
|
|
3740
|
+
});
|
|
3741
|
+
const historyView = defineView(history, {
|
|
3742
|
+
pattern: "materializedView",
|
|
3743
|
+
source: { entity: input.source, maintainedOn: input.maintainedOn, keys: input.historyKeys },
|
|
3744
|
+
fields: input.fields,
|
|
3745
|
+
...input.write ? { write: input.write } : {}
|
|
3746
|
+
});
|
|
3747
|
+
return { latest: latestView, history: historyView };
|
|
3748
|
+
}
|
|
3749
|
+
var PROJECTION_DEFINITION_MARKER = /* @__PURE__ */ Symbol(
|
|
3750
|
+
"graphddb:projectionDefinition"
|
|
3751
|
+
);
|
|
3752
|
+
function isProjectionDefinition(value) {
|
|
3753
|
+
return typeof value === "object" && value !== null && value[PROJECTION_DEFINITION_MARKER] === true;
|
|
3754
|
+
}
|
|
3755
|
+
function defineProjection(name, input) {
|
|
3756
|
+
if (typeof name !== "string" || name.length === 0) {
|
|
3757
|
+
throw new Error("defineProjection(name, input): `name` must be a non-empty string.");
|
|
3758
|
+
}
|
|
3759
|
+
if (input.pattern !== void 0 && input.pattern !== "externalProjection") {
|
|
3760
|
+
throw new Error(
|
|
3761
|
+
`defineProjection('${name}'): \`pattern\` must be 'externalProjection' (got ${JSON.stringify(input.pattern)}).`
|
|
3762
|
+
);
|
|
3763
|
+
}
|
|
3764
|
+
const via = input.via ?? "stream";
|
|
3765
|
+
if (via !== "stream") {
|
|
3766
|
+
throw new Error(
|
|
3767
|
+
`defineProjection('${name}'): \`via\` must be 'stream' (the only supported transport this phase; got ${JSON.stringify(via)}). An external projection is maintained asynchronously off the CDC stream.`
|
|
3768
|
+
);
|
|
3769
|
+
}
|
|
3770
|
+
if (typeof input.idempotencyKey !== "string" || input.idempotencyKey.length === 0) {
|
|
3771
|
+
throw new Error(
|
|
3772
|
+
`defineProjection('${name}'): \`idempotencyKey\` must be a non-empty source attribute name (e.g. 'resultId'). It folds at-least-once delivery to one sink upsert per genuine source event.`
|
|
3773
|
+
);
|
|
3774
|
+
}
|
|
3775
|
+
const on = input.on ?? ["created", "updated", "removed"];
|
|
3776
|
+
const project = input.fields ? buildProjection(name, input.fields) : {};
|
|
3777
|
+
return {
|
|
3778
|
+
[PROJECTION_DEFINITION_MARKER]: true,
|
|
3779
|
+
name,
|
|
3780
|
+
pattern: "externalProjection",
|
|
3781
|
+
sourceClass: resolveSourceClass(input.source),
|
|
3782
|
+
via,
|
|
3783
|
+
idempotencyKey: input.idempotencyKey,
|
|
3784
|
+
on: [...on],
|
|
3785
|
+
project
|
|
3786
|
+
};
|
|
3787
|
+
}
|
|
3788
|
+
function defineProjections(definitions) {
|
|
3789
|
+
for (const [name, def] of Object.entries(definitions)) {
|
|
3790
|
+
if (!isProjectionDefinition(def)) {
|
|
3791
|
+
throw new Error(
|
|
3792
|
+
`defineProjections: '${name}' is not a projection definition. Use defineProjection(name, { pattern: 'externalProjection', source, via, idempotencyKey }) to build entries.`
|
|
3793
|
+
);
|
|
3794
|
+
}
|
|
3795
|
+
}
|
|
3796
|
+
return definitions;
|
|
3797
|
+
}
|
|
3798
|
+
|
|
3799
|
+
// src/relation/maintenance-graph.ts
|
|
3800
|
+
var COUNT_DELTA_FOR_EVENT = {
|
|
3801
|
+
created: 1,
|
|
3802
|
+
removed: -1,
|
|
3803
|
+
updated: 0
|
|
3804
|
+
};
|
|
3805
|
+
var MAINTAIN_EVENTS = ["created", "updated", "removed"];
|
|
3806
|
+
function isPathRooted2(value) {
|
|
3807
|
+
return value.startsWith("$.input.") || value.startsWith("$.entity.");
|
|
3808
|
+
}
|
|
3809
|
+
function sourceAttributeOf(path) {
|
|
3810
|
+
const stripped = path.replace(/^\$\.(input|entity)\./, "");
|
|
3811
|
+
return stripped.split(".")[0] ?? stripped;
|
|
3812
|
+
}
|
|
3813
|
+
function normalizeProjectionEntry(value) {
|
|
3814
|
+
if (typeof value === "string") {
|
|
3815
|
+
const path = isPathRooted2(value) ? value : `$.entity.${value}`;
|
|
3816
|
+
return identity(path);
|
|
3817
|
+
}
|
|
3818
|
+
return value;
|
|
3819
|
+
}
|
|
3820
|
+
function buildProjectionMap(projection, ownerEntity, relationProperty) {
|
|
3821
|
+
const entries = projection ? Object.entries(projection) : [];
|
|
3822
|
+
if (entries.length === 0) {
|
|
3823
|
+
throw new Error(
|
|
3824
|
+
`Relation '${relationProperty}' on '${ownerEntity}' declares maintenance (\`write.maintainedOn\`) but no \`projection\`: a maintained snapshot / collection with no captured attributes projects nothing. Declare a \`projection\` map (e.g. \`{ postId: 'postId', textPreview: preview('body', 120) }\`).`
|
|
3825
|
+
);
|
|
3826
|
+
}
|
|
3827
|
+
const out = {};
|
|
3828
|
+
for (const [attr, value] of entries) {
|
|
3829
|
+
out[attr] = normalizeProjectionEntry(value);
|
|
3830
|
+
}
|
|
3831
|
+
return out;
|
|
3832
|
+
}
|
|
3833
|
+
function buildDestinationKeys(keyBinding) {
|
|
3834
|
+
const keys = {};
|
|
3835
|
+
const sourceFields = [];
|
|
3836
|
+
for (const [sourceField, ownerField] of Object.entries(keyBinding)) {
|
|
3837
|
+
keys[ownerField] = `$.entity.${sourceField}`;
|
|
3838
|
+
sourceFields.push(sourceField);
|
|
3839
|
+
}
|
|
3840
|
+
return { keys, sourceFields };
|
|
3841
|
+
}
|
|
3842
|
+
function sourcePayloadAttributes(meta) {
|
|
3843
|
+
const attrs = /* @__PURE__ */ new Set();
|
|
3844
|
+
for (const f of meta.fields) attrs.add(f.propertyName);
|
|
3845
|
+
if (meta.primaryKey) {
|
|
3846
|
+
for (const f of segmentFieldNames(meta.primaryKey.segmented.pkSegments)) attrs.add(f);
|
|
3847
|
+
for (const f of segmentFieldNames(meta.primaryKey.segmented.skSegments)) attrs.add(f);
|
|
3848
|
+
}
|
|
3849
|
+
for (const gsi2 of meta.gsiDefinitions) {
|
|
3850
|
+
for (const f of segmentFieldNames(gsi2.segmented.pkSegments)) attrs.add(f);
|
|
3851
|
+
for (const f of segmentFieldNames(gsi2.segmented.skSegments)) attrs.add(f);
|
|
3852
|
+
}
|
|
3853
|
+
return attrs;
|
|
3854
|
+
}
|
|
3855
|
+
function assertMaintenanceRoundTrips(args) {
|
|
3856
|
+
const {
|
|
3857
|
+
ownerEntity,
|
|
3858
|
+
relationProperty,
|
|
3859
|
+
ownerMeta,
|
|
3860
|
+
sourceEntity,
|
|
3861
|
+
sourceMeta,
|
|
3862
|
+
destinationKeyFields,
|
|
3863
|
+
keyBindingSourceFields,
|
|
3864
|
+
projection
|
|
3865
|
+
} = args;
|
|
3866
|
+
const sourceAttrs = sourcePayloadAttributes(sourceMeta);
|
|
3867
|
+
try {
|
|
3868
|
+
resolveKey([...destinationKeyFields], ownerMeta);
|
|
3869
|
+
} catch (cause) {
|
|
3870
|
+
throw new Error(
|
|
3871
|
+
`Maintenance declaration for relation '${relationProperty}' on '${ownerEntity}' binds destination-key fields [${destinationKeyFields.map((f) => `'${f}'`).join(", ")}], which cover no access-pattern partition of the maintained entity '${ownerEntity}'. The maintained snapshot / collection row could not be resolved, so the snapshot key would not point at a real target row \u2014 bind the partition-key field(s) of the index the maintained row lives on. (${cause.message})`
|
|
3872
|
+
);
|
|
3873
|
+
}
|
|
3874
|
+
for (const field of keyBindingSourceFields) {
|
|
3875
|
+
if (!sourceAttrs.has(field)) {
|
|
3876
|
+
throw new Error(
|
|
3877
|
+
`Maintenance key binding for relation '${relationProperty}' on '${ownerEntity}' reads source field '${field}' to resolve the destination row, but the source entity '${sourceEntity}' carries no attribute '${field}' on its payload (available: ${[...sourceAttrs].sort().map((f) => `'${f}'`).join(", ")}). The destination key could not be built from the source payload \u2014 reject.`
|
|
3878
|
+
);
|
|
3879
|
+
}
|
|
3880
|
+
}
|
|
3881
|
+
for (const [attr, transform] of Object.entries(projection)) {
|
|
3882
|
+
if (!isPathRooted2(transform.path)) {
|
|
3883
|
+
throw new Error(
|
|
3884
|
+
`Maintenance projection for relation '${relationProperty}' on '${ownerEntity}' captures attribute '${attr}' from '${transform.path}', which is not a payload-rooted source path (\`$.input.*\` / \`$.entity.*\`). The maintenance graph projects only from the source payload (payload \u540C\u68B1) \u2014 there is no fetch / re-projection.`
|
|
3885
|
+
);
|
|
3886
|
+
}
|
|
3887
|
+
const srcAttr = sourceAttributeOf(transform.path);
|
|
3888
|
+
if (!sourceAttrs.has(srcAttr)) {
|
|
3889
|
+
throw new Error(
|
|
3890
|
+
`Maintenance projection for relation '${relationProperty}' on '${ownerEntity}' captures '${attr}' from '${transform.path}', but the source entity '${sourceEntity}' carries no attribute '${srcAttr}' on its payload (available: ${[...sourceAttrs].sort().map((f) => `'${f}'`).join(", ")}). Projection is payload \u540C\u68B1 only (no fetch / re-projection): an attribute the source row does not carry cannot be projected \u2014 reject.`
|
|
3891
|
+
);
|
|
3892
|
+
}
|
|
3893
|
+
}
|
|
3894
|
+
}
|
|
3895
|
+
function assertCounterRoundTrips(args) {
|
|
3896
|
+
const {
|
|
3897
|
+
ownerEntity,
|
|
3898
|
+
aggregateField,
|
|
3899
|
+
ownerMeta,
|
|
3900
|
+
sourceEntity,
|
|
3901
|
+
sourceMeta,
|
|
3902
|
+
destinationKeyFields,
|
|
3903
|
+
keyBindingSourceFields,
|
|
3904
|
+
valueSourceFields
|
|
3905
|
+
} = args;
|
|
3906
|
+
const sourceAttrs = sourcePayloadAttributes(sourceMeta);
|
|
3907
|
+
try {
|
|
3908
|
+
resolveKey([...destinationKeyFields], ownerMeta);
|
|
3909
|
+
} catch (cause) {
|
|
3910
|
+
throw new Error(
|
|
3911
|
+
`Counter aggregate '${aggregateField}' on '${ownerEntity}' binds destination-key fields [${destinationKeyFields.map((f) => `'${f}'`).join(", ")}], which cover no access-pattern partition of the maintained entity '${ownerEntity}'. The counter row could not be resolved \u2014 bind the partition-key field(s) of the index the counter row lives on. (${cause.message})`
|
|
3912
|
+
);
|
|
3913
|
+
}
|
|
3914
|
+
for (const field of keyBindingSourceFields) {
|
|
3915
|
+
if (!sourceAttrs.has(field)) {
|
|
3916
|
+
throw new Error(
|
|
3917
|
+
`Counter aggregate '${aggregateField}' on '${ownerEntity}' reads source field '${field}' to resolve the counter row, but the source entity '${sourceEntity}' carries no attribute '${field}' on its payload (available: ${[...sourceAttrs].sort().map((f) => `'${f}'`).join(", ")}). The destination key could not be built from the source payload \u2014 reject.`
|
|
3918
|
+
);
|
|
3919
|
+
}
|
|
3920
|
+
}
|
|
3921
|
+
for (const field of valueSourceFields) {
|
|
3922
|
+
if (!sourceAttrs.has(field)) {
|
|
3923
|
+
throw new Error(
|
|
3924
|
+
`Counter aggregate '${aggregateField}' on '${ownerEntity}' tracks \`max('${field}')\`, but the source entity '${sourceEntity}' carries no attribute '${field}' on its payload (available: ${[...sourceAttrs].sort().map((f) => `'${f}'`).join(", ")}). A counter aggregates only the source payload (payload \u540C\u68B1) \u2014 reject.`
|
|
3925
|
+
);
|
|
3926
|
+
}
|
|
3927
|
+
}
|
|
3928
|
+
}
|
|
3929
|
+
function logicalNameOf(meta) {
|
|
3930
|
+
return meta.prefix.endsWith("#") ? meta.prefix.slice(0, -1) : meta.prefix;
|
|
3931
|
+
}
|
|
3932
|
+
function resolveEntityByName(name, registry) {
|
|
3933
|
+
for (const [klass, meta] of registry) {
|
|
3934
|
+
if (logicalNameOf(meta) === name) return klass;
|
|
3935
|
+
}
|
|
3936
|
+
return void 0;
|
|
3937
|
+
}
|
|
3938
|
+
function parseTrigger(raw) {
|
|
3939
|
+
const idx = raw.lastIndexOf(".");
|
|
3940
|
+
return { entity: raw.slice(0, idx), event: raw.slice(idx + 1) };
|
|
3941
|
+
}
|
|
3942
|
+
function buildEffect(relation, ownerFactory, trigger, keys, project, sourceMeta, ownerEntity) {
|
|
3943
|
+
const write = relation.options?.write;
|
|
3944
|
+
const updateMode = write?.updateMode;
|
|
3945
|
+
const consistency = write?.consistency;
|
|
3946
|
+
const base = {
|
|
3947
|
+
trigger,
|
|
3948
|
+
targetFactory: ownerFactory,
|
|
3949
|
+
keys,
|
|
3950
|
+
project,
|
|
3951
|
+
...updateMode !== void 0 ? { updateMode } : {},
|
|
3952
|
+
...consistency !== void 0 ? { consistency } : {}
|
|
3953
|
+
};
|
|
3954
|
+
if (relation.type === "hasMany") {
|
|
3955
|
+
const read = relation.options?.read;
|
|
3956
|
+
const order = resolveCollectionOrder(relation, sourceMeta, ownerEntity, project);
|
|
3957
|
+
const collection = {
|
|
3958
|
+
field: relation.propertyName,
|
|
3959
|
+
...read?.maxItems !== void 0 ? { maxItems: read.maxItems } : {},
|
|
3960
|
+
...order !== void 0 ? { orderBy: order.orderBy, orderDir: order.orderDir } : {}
|
|
3961
|
+
};
|
|
3962
|
+
return { kind: "collection", ...base, collection };
|
|
3963
|
+
}
|
|
3964
|
+
return { kind: "snapshot", ...base };
|
|
3965
|
+
}
|
|
3966
|
+
function viewSliceProperty(viewName, sliceIndex, slice) {
|
|
3967
|
+
return slice.collection ? `${viewName}#${slice.collection.field}` : `${viewName}#${sliceIndex}`;
|
|
3968
|
+
}
|
|
3969
|
+
function buildViewEffect(view, slice, trigger) {
|
|
3970
|
+
const targetFactory = () => view.viewClass;
|
|
3971
|
+
const base = {
|
|
3972
|
+
trigger,
|
|
3973
|
+
targetFactory,
|
|
3974
|
+
keys: slice.keys,
|
|
3975
|
+
project: slice.project,
|
|
3976
|
+
...view.updateMode !== void 0 ? { updateMode: view.updateMode } : {},
|
|
3977
|
+
...view.consistency !== void 0 ? { consistency: view.consistency } : {}
|
|
3978
|
+
};
|
|
3979
|
+
if (slice.predicate) {
|
|
3980
|
+
return { kind: "membership", ...base, predicate: slice.predicate };
|
|
3981
|
+
}
|
|
3982
|
+
if (slice.collection) {
|
|
3983
|
+
const collection = {
|
|
3984
|
+
field: slice.collection.field,
|
|
3985
|
+
...slice.collection.maxItems !== void 0 ? { maxItems: slice.collection.maxItems } : {},
|
|
3986
|
+
...slice.collection.orderBy !== void 0 ? {
|
|
3987
|
+
orderBy: slice.collection.orderBy,
|
|
3988
|
+
orderDir: slice.collection.orderDir ?? "DESC"
|
|
3989
|
+
} : {}
|
|
3990
|
+
};
|
|
3991
|
+
return { kind: "collection", ...base, collection };
|
|
3992
|
+
}
|
|
3993
|
+
return { kind: "snapshot", ...base };
|
|
3994
|
+
}
|
|
3995
|
+
function resolveCollectionOrder(relation, sourceMeta, ownerEntity, project) {
|
|
3996
|
+
const order = relation.options?.read?.order;
|
|
3997
|
+
if (order === void 0) return void 0;
|
|
3998
|
+
const skFields = sourceMeta.primaryKey ? segmentFieldNames(sourceMeta.primaryKey.segmented.skSegments) : [];
|
|
3999
|
+
const orderField = skFields[skFields.length - 1];
|
|
4000
|
+
if (orderField === void 0) {
|
|
4001
|
+
throw new Error(
|
|
4002
|
+
`Relation '${relation.propertyName}' on '${ownerEntity}' declares \`read.order: '${order}'\` for its maintained collection, but its source entity '${logicalNameOf(sourceMeta)}' has no sort key to order by. A maintained collection orders by the source's sort-key field (the field the live read sorts on); declare a sort key on '${logicalNameOf(sourceMeta)}' or drop \`read.order\`.`
|
|
4003
|
+
);
|
|
4004
|
+
}
|
|
4005
|
+
if (!Object.prototype.hasOwnProperty.call(project, orderField)) {
|
|
4006
|
+
throw new Error(
|
|
4007
|
+
`Relation '${relation.propertyName}' on '${ownerEntity}' declares \`read.order: '${order}'\` for its maintained collection, which orders by the source entity '${logicalNameOf(sourceMeta)}'s sort-key field '${orderField}', but that field is not in the relation's \`projection\` (projected: ${Object.keys(project).map((a) => `'${a}'`).join(", ")}). The maintained collection sorts its projected items, so the order field must be projected \u2014 add '${orderField}' to the projection (e.g. \`{ ${orderField}: '${orderField}', \u2026 }\`) or drop \`read.order\`.`
|
|
4008
|
+
);
|
|
4009
|
+
}
|
|
4010
|
+
return { orderBy: `$.entity.${orderField}`, orderDir: order };
|
|
4011
|
+
}
|
|
4012
|
+
function buildCounterEffect(aggregate, ownerFactory, trigger, event, keys) {
|
|
4013
|
+
const write = aggregate.options.write;
|
|
4014
|
+
const updateMode = write?.updateMode;
|
|
4015
|
+
const consistency = write?.consistency;
|
|
4016
|
+
const value = aggregate.options.value.op === "count" ? { op: "count" } : { op: "max", field: aggregate.options.value.field };
|
|
4017
|
+
return {
|
|
4018
|
+
kind: "counter",
|
|
4019
|
+
trigger,
|
|
4020
|
+
targetFactory: ownerFactory,
|
|
4021
|
+
keys,
|
|
4022
|
+
attribute: aggregate.propertyName,
|
|
4023
|
+
value,
|
|
4024
|
+
...value.op === "count" ? { delta: COUNT_DELTA_FOR_EVENT[event] } : {},
|
|
4025
|
+
...updateMode !== void 0 ? { updateMode } : {},
|
|
4026
|
+
...consistency !== void 0 ? { consistency } : {}
|
|
4027
|
+
};
|
|
4028
|
+
}
|
|
4029
|
+
function destinationRowKeyOf(ownerEntity, keys) {
|
|
4030
|
+
const parts = Object.entries(keys).map(([field, path]) => `${field}=${path}`).sort();
|
|
4031
|
+
return `${ownerEntity}#${parts.join("&")}`;
|
|
4032
|
+
}
|
|
4033
|
+
function assertNoCycle(items) {
|
|
4034
|
+
const adjacency = /* @__PURE__ */ new Map();
|
|
4035
|
+
for (const item of items) {
|
|
4036
|
+
if (!adjacency.has(item.sourceEntity)) adjacency.set(item.sourceEntity, /* @__PURE__ */ new Set());
|
|
4037
|
+
adjacency.get(item.sourceEntity).add(item.ownerEntity);
|
|
4038
|
+
}
|
|
4039
|
+
const WHITE = 0;
|
|
4040
|
+
const GRAY = 1;
|
|
4041
|
+
const BLACK = 2;
|
|
4042
|
+
const color = /* @__PURE__ */ new Map();
|
|
4043
|
+
for (const node of adjacency.keys()) color.set(node, WHITE);
|
|
4044
|
+
for (const start of adjacency.keys()) {
|
|
4045
|
+
if (color.get(start) !== WHITE) continue;
|
|
4046
|
+
const stack = [];
|
|
4047
|
+
const path = [];
|
|
4048
|
+
color.set(start, GRAY);
|
|
4049
|
+
path.push(start);
|
|
4050
|
+
stack.push({ node: start, iter: (adjacency.get(start) ?? /* @__PURE__ */ new Set()).values() });
|
|
4051
|
+
while (stack.length > 0) {
|
|
4052
|
+
const top = stack[stack.length - 1];
|
|
4053
|
+
const next = top.iter.next();
|
|
4054
|
+
if (next.done) {
|
|
4055
|
+
color.set(top.node, BLACK);
|
|
4056
|
+
stack.pop();
|
|
4057
|
+
path.pop();
|
|
4058
|
+
continue;
|
|
4059
|
+
}
|
|
4060
|
+
const child = next.value;
|
|
4061
|
+
const childColor = color.get(child) ?? WHITE;
|
|
4062
|
+
if (childColor === GRAY) {
|
|
4063
|
+
const from = path.indexOf(child);
|
|
4064
|
+
const cycle = [...path.slice(from), child].join(" \u2192 ");
|
|
4065
|
+
throw new Error(
|
|
4066
|
+
`Maintenance dependency cycle detected: ${cycle}. A maintenance trigger chain that loops back on itself is an unbounded cascade \u2014 break the cycle (an entity's maintained shape must not, transitively, be triggered by its own maintenance).`
|
|
4067
|
+
);
|
|
4068
|
+
}
|
|
4069
|
+
if (childColor === WHITE) {
|
|
4070
|
+
color.set(child, GRAY);
|
|
4071
|
+
path.push(child);
|
|
4072
|
+
stack.push({ node: child, iter: (adjacency.get(child) ?? /* @__PURE__ */ new Set()).values() });
|
|
4073
|
+
}
|
|
4074
|
+
}
|
|
4075
|
+
}
|
|
4076
|
+
}
|
|
4077
|
+
function buildMaintenanceGraph(registry, views) {
|
|
4078
|
+
const useGlobal = registry === void 0;
|
|
4079
|
+
const resolvedRegistry = registry ?? MetadataRegistry.getAll();
|
|
4080
|
+
const resolvedViews = views ?? (useGlobal ? ViewRegistry.getAll() : []);
|
|
4081
|
+
return buildMaintenanceGraphImpl(resolvedRegistry, resolvedViews);
|
|
4082
|
+
}
|
|
4083
|
+
function buildMaintenanceGraphImpl(registry, views) {
|
|
4084
|
+
const items = [];
|
|
4085
|
+
const owners = [...registry.entries()].map(([klass, meta]) => ({ klass, meta })).sort((a, b) => a.klass.name < b.klass.name ? -1 : a.klass.name > b.klass.name ? 1 : 0);
|
|
4086
|
+
for (const { klass: ownerClass, meta: ownerMeta } of owners) {
|
|
4087
|
+
const ownerEntity = logicalNameOf(ownerMeta);
|
|
4088
|
+
const relations = [...ownerMeta.relations].sort(
|
|
4089
|
+
(a, b) => a.propertyName < b.propertyName ? -1 : a.propertyName > b.propertyName ? 1 : 0
|
|
4090
|
+
);
|
|
4091
|
+
for (const relation of relations) {
|
|
4092
|
+
const maintainedOn = relation.options?.write?.maintainedOn;
|
|
4093
|
+
if (!maintainedOn || maintainedOn.length === 0) continue;
|
|
4094
|
+
const sourceClass = relation.targetFactory();
|
|
4095
|
+
const sourceMeta = MetadataRegistry.get(sourceClass);
|
|
4096
|
+
const relationTargetEntity = logicalNameOf(sourceMeta);
|
|
4097
|
+
const project = buildProjectionMap(
|
|
4098
|
+
relation.options?.projection,
|
|
4099
|
+
ownerEntity,
|
|
4100
|
+
relation.propertyName
|
|
4101
|
+
);
|
|
4102
|
+
const { keys, sourceFields } = buildDestinationKeys(relation.keyBinding);
|
|
4103
|
+
const destinationKeyFields = Object.keys(keys);
|
|
4104
|
+
for (const raw of maintainedOn) {
|
|
4105
|
+
if (!isMaintainTrigger(raw)) {
|
|
4106
|
+
const { event } = parseTrigger(raw);
|
|
4107
|
+
throw new Error(
|
|
4108
|
+
`Maintenance trigger ${JSON.stringify(raw)} on relation '${relation.propertyName}' of '${ownerEntity}' is not a well-formed \`Entity.event\` trigger (event must be one of ${MAINTAIN_EVENTS.map((e) => `'${e}'`).join(" / ")}; got '${event}'). Fix the \`write.maintainedOn\` entry.`
|
|
4109
|
+
);
|
|
4110
|
+
}
|
|
4111
|
+
const trigger = raw;
|
|
4112
|
+
const { entity: triggerEntity } = parseTrigger(trigger);
|
|
4113
|
+
if (!resolveEntityByName(triggerEntity, registry)) {
|
|
4114
|
+
throw new Error(
|
|
4115
|
+
`Maintenance trigger '${trigger}' on relation '${relation.propertyName}' of '${ownerEntity}' names source entity '${triggerEntity}', which is not a registered model. Declare a model named '${triggerEntity}' or fix the trigger's entity segment.`
|
|
4116
|
+
);
|
|
4117
|
+
}
|
|
4118
|
+
if (triggerEntity !== relationTargetEntity) {
|
|
4119
|
+
throw new Error(
|
|
4120
|
+
`Maintenance trigger '${trigger}' on relation '${relation.propertyName}' of '${ownerEntity}' names source entity '${triggerEntity}', but the relation's target (the projected source) is '${relationTargetEntity}'. #124 projects only from the relation target's payload (payload \u540C\u68B1); a trigger on a different entity needs a write-time context fetch, which is Phase 2 \u2014 restrict \`maintainedOn\` to '${relationTargetEntity}.*' triggers.`
|
|
4121
|
+
);
|
|
4122
|
+
}
|
|
4123
|
+
assertMaintenanceRoundTrips({
|
|
4124
|
+
ownerEntity,
|
|
4125
|
+
relationProperty: relation.propertyName,
|
|
4126
|
+
ownerMeta,
|
|
4127
|
+
sourceEntity: triggerEntity,
|
|
4128
|
+
sourceMeta,
|
|
4129
|
+
destinationKeyFields,
|
|
4130
|
+
keyBindingSourceFields: sourceFields,
|
|
4131
|
+
projection: project
|
|
4132
|
+
});
|
|
4133
|
+
const effect = buildEffect(
|
|
4134
|
+
relation,
|
|
4135
|
+
() => ownerClass,
|
|
4136
|
+
trigger,
|
|
4137
|
+
keys,
|
|
4138
|
+
project,
|
|
4139
|
+
sourceMeta,
|
|
4140
|
+
ownerEntity
|
|
4141
|
+
);
|
|
4142
|
+
items.push({
|
|
4143
|
+
trigger,
|
|
4144
|
+
sourceEntity: triggerEntity,
|
|
4145
|
+
sourceClass,
|
|
4146
|
+
ownerEntity,
|
|
4147
|
+
ownerClass,
|
|
4148
|
+
relationProperty: relation.propertyName,
|
|
4149
|
+
destinationRowKey: destinationRowKeyOf(ownerEntity, keys),
|
|
4150
|
+
effect
|
|
4151
|
+
});
|
|
4152
|
+
}
|
|
4153
|
+
}
|
|
4154
|
+
const aggregates = [...ownerMeta.aggregates].sort(
|
|
4155
|
+
(a, b) => a.propertyName < b.propertyName ? -1 : a.propertyName > b.propertyName ? 1 : 0
|
|
4156
|
+
);
|
|
4157
|
+
for (const agg of aggregates) {
|
|
4158
|
+
const maintainedOn = agg.options.write?.maintainedOn;
|
|
4159
|
+
if (!maintainedOn || maintainedOn.length === 0) continue;
|
|
4160
|
+
const sourceClass = agg.targetFactory();
|
|
4161
|
+
const sourceMeta = MetadataRegistry.get(sourceClass);
|
|
4162
|
+
const aggSourceEntity = logicalNameOf(sourceMeta);
|
|
4163
|
+
const { keys, sourceFields } = buildDestinationKeys(agg.keyBinding);
|
|
4164
|
+
const destinationKeyFields = Object.keys(keys);
|
|
4165
|
+
const valueSourceFields = agg.options.value.op === "max" ? [agg.options.value.field] : [];
|
|
4166
|
+
for (const raw of maintainedOn) {
|
|
4167
|
+
if (!isMaintainTrigger(raw)) {
|
|
4168
|
+
const { event: event2 } = parseTrigger(raw);
|
|
4169
|
+
throw new Error(
|
|
4170
|
+
`Maintenance trigger ${JSON.stringify(raw)} on \`@aggregate\` field '${agg.propertyName}' of '${ownerEntity}' is not a well-formed \`Entity.event\` trigger (event must be one of ${MAINTAIN_EVENTS.map((e) => `'${e}'`).join(" / ")}; got '${event2}'). Fix the \`write.maintainedOn\` entry.`
|
|
4171
|
+
);
|
|
4172
|
+
}
|
|
4173
|
+
const trigger = raw;
|
|
4174
|
+
const parsed = parseTrigger(trigger);
|
|
4175
|
+
const triggerEntity = parsed.entity;
|
|
4176
|
+
const event = parsed.event;
|
|
4177
|
+
if (!resolveEntityByName(triggerEntity, registry)) {
|
|
4178
|
+
throw new Error(
|
|
4179
|
+
`Maintenance trigger '${trigger}' on \`@aggregate\` field '${agg.propertyName}' of '${ownerEntity}' names source entity '${triggerEntity}', which is not a registered model. Declare a model named '${triggerEntity}' or fix the trigger's entity segment.`
|
|
4180
|
+
);
|
|
4181
|
+
}
|
|
4182
|
+
if (triggerEntity !== aggSourceEntity) {
|
|
4183
|
+
throw new Error(
|
|
4184
|
+
`Maintenance trigger '${trigger}' on \`@aggregate\` field '${agg.propertyName}' of '${ownerEntity}' names source entity '${triggerEntity}', but the aggregate's source (the entity being counted) is '${aggSourceEntity}'. A counter aggregates only the rows of its own source (payload \u540C\u68B1); a trigger on a different entity is Phase 2 \u2014 restrict \`maintainedOn\` to '${aggSourceEntity}.*' triggers.`
|
|
4185
|
+
);
|
|
4186
|
+
}
|
|
4187
|
+
if (agg.options.value.op === "count" && COUNT_DELTA_FOR_EVENT[event] === 0) {
|
|
4188
|
+
throw new Error(
|
|
4189
|
+
`\`@aggregate\` field '${agg.propertyName}' of '${ownerEntity}' is a \`count()\` counter maintained on '${trigger}', but a 'updated' event does not change a row count (it adds 0). A \`count()\` counter is maintained on 'created' (+1) and 'removed' (-1); drop the 'updated' trigger.`
|
|
4190
|
+
);
|
|
4191
|
+
}
|
|
4192
|
+
assertCounterRoundTrips({
|
|
4193
|
+
ownerEntity,
|
|
4194
|
+
aggregateField: agg.propertyName,
|
|
4195
|
+
ownerMeta,
|
|
4196
|
+
sourceEntity: triggerEntity,
|
|
4197
|
+
sourceMeta,
|
|
4198
|
+
destinationKeyFields,
|
|
4199
|
+
keyBindingSourceFields: sourceFields,
|
|
4200
|
+
valueSourceFields
|
|
4201
|
+
});
|
|
4202
|
+
const effect = buildCounterEffect(agg, () => ownerClass, trigger, event, keys);
|
|
4203
|
+
items.push({
|
|
4204
|
+
trigger,
|
|
4205
|
+
sourceEntity: triggerEntity,
|
|
4206
|
+
sourceClass,
|
|
4207
|
+
ownerEntity,
|
|
4208
|
+
ownerClass,
|
|
4209
|
+
relationProperty: agg.propertyName,
|
|
4210
|
+
destinationRowKey: destinationRowKeyOf(ownerEntity, keys),
|
|
4211
|
+
effect
|
|
4212
|
+
});
|
|
4213
|
+
}
|
|
4214
|
+
}
|
|
4215
|
+
}
|
|
4216
|
+
for (const view of [...views].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) {
|
|
4217
|
+
const ownerClass = view.viewClass;
|
|
4218
|
+
const ownerMeta = MetadataRegistry.get(ownerClass);
|
|
4219
|
+
const ownerEntity = logicalNameOf(ownerMeta);
|
|
4220
|
+
view.slices.forEach((slice, sliceIndex) => {
|
|
4221
|
+
const sourceClass = slice.sourceClass;
|
|
4222
|
+
const sourceMeta = MetadataRegistry.get(sourceClass);
|
|
4223
|
+
const sourceEntity = logicalNameOf(sourceMeta);
|
|
4224
|
+
const keys = slice.keys;
|
|
4225
|
+
const destinationKeyFields = Object.keys(keys);
|
|
4226
|
+
const keyBindingSourceFields = Object.values(keys).map((p) => sourceAttributeOf(p));
|
|
4227
|
+
for (const trigger of slice.maintainedOn) {
|
|
4228
|
+
const { entity: triggerEntity } = parseTrigger(trigger);
|
|
4229
|
+
if (triggerEntity !== sourceEntity) {
|
|
4230
|
+
throw new Error(
|
|
4231
|
+
`defineView '${view.name}': source slice trigger '${trigger}' names entity '${triggerEntity}', but the slice's source entity is '${sourceEntity}'. A view source projects only from its own payload (payload \u540C\u68B1); a trigger on a different entity needs a write-time context fetch (RFC \xA74.A.6), which is a deferred follow-up \u2014 restrict the slice's \`maintainedOn\` to '${sourceEntity}.*' triggers.`
|
|
4232
|
+
);
|
|
4233
|
+
}
|
|
4234
|
+
assertMaintenanceRoundTrips({
|
|
4235
|
+
ownerEntity,
|
|
4236
|
+
relationProperty: viewSliceProperty(view.name, sliceIndex, slice),
|
|
4237
|
+
ownerMeta,
|
|
4238
|
+
sourceEntity,
|
|
4239
|
+
sourceMeta,
|
|
4240
|
+
destinationKeyFields,
|
|
4241
|
+
keyBindingSourceFields,
|
|
4242
|
+
projection: slice.project
|
|
4243
|
+
});
|
|
4244
|
+
if (slice.predicate) {
|
|
4245
|
+
if (!isPathRooted2(slice.predicate.path)) {
|
|
4246
|
+
throw new Error(
|
|
4247
|
+
`defineView '${view.name}': sparse-view predicate path ${JSON.stringify(slice.predicate.path)} is not a payload-rooted source path (\`$.input.*\` / \`$.entity.*\`). The drain evaluates the predicate against the source image \u2014 use a source attribute path.`
|
|
4248
|
+
);
|
|
4249
|
+
}
|
|
4250
|
+
const predAttr = sourceAttributeOf(slice.predicate.path);
|
|
4251
|
+
if (!sourcePayloadAttributes(sourceMeta).has(predAttr)) {
|
|
4252
|
+
throw new Error(
|
|
4253
|
+
`defineView '${view.name}': sparse-view predicate reads source attribute '${predAttr}' (from '${slice.predicate.path}'), but the source entity '${sourceEntity}' carries no such attribute on its payload (available: ${[...sourcePayloadAttributes(sourceMeta)].sort().map((f) => `'${f}'`).join(", ")}). A membership predicate is payload \u540C\u68B1 only \u2014 reject.`
|
|
4254
|
+
);
|
|
4255
|
+
}
|
|
4256
|
+
}
|
|
4257
|
+
const effect = buildViewEffect(view, slice, trigger);
|
|
4258
|
+
items.push({
|
|
4259
|
+
trigger,
|
|
4260
|
+
sourceEntity,
|
|
4261
|
+
sourceClass,
|
|
4262
|
+
ownerEntity,
|
|
4263
|
+
ownerClass,
|
|
4264
|
+
relationProperty: viewSliceProperty(view.name, sliceIndex, slice),
|
|
4265
|
+
destinationRowKey: destinationRowKeyOf(ownerEntity, keys),
|
|
4266
|
+
effect
|
|
4267
|
+
});
|
|
4268
|
+
}
|
|
4269
|
+
});
|
|
4270
|
+
}
|
|
4271
|
+
assertNoCycle(items);
|
|
4272
|
+
const byTrigger = /* @__PURE__ */ new Map();
|
|
4273
|
+
const sameRow = /* @__PURE__ */ new Map();
|
|
4274
|
+
for (const item of items) {
|
|
4275
|
+
const triggerBucket = byTrigger.get(item.trigger);
|
|
4276
|
+
if (triggerBucket) triggerBucket.push(item);
|
|
4277
|
+
else byTrigger.set(item.trigger, [item]);
|
|
4278
|
+
const rowKey = `${item.trigger}\0${item.destinationRowKey}`;
|
|
4279
|
+
const rowBucket = sameRow.get(rowKey);
|
|
4280
|
+
if (rowBucket) rowBucket.push(item);
|
|
4281
|
+
else sameRow.set(rowKey, [item]);
|
|
4282
|
+
}
|
|
4283
|
+
const multiMaintainerTargets = /* @__PURE__ */ new Map();
|
|
4284
|
+
for (const [rowKey, bucket] of sameRow) {
|
|
4285
|
+
if (bucket.length > 1) multiMaintainerTargets.set(rowKey, bucket);
|
|
4286
|
+
}
|
|
4287
|
+
return {
|
|
4288
|
+
items,
|
|
4289
|
+
byTrigger,
|
|
4290
|
+
effectsFor(trigger) {
|
|
4291
|
+
return byTrigger.get(trigger) ?? [];
|
|
4292
|
+
},
|
|
4293
|
+
multiMaintainerTargets
|
|
4294
|
+
};
|
|
4295
|
+
}
|
|
4296
|
+
|
|
2964
4297
|
export {
|
|
2965
4298
|
isColumn,
|
|
2966
4299
|
createColumnMap,
|
|
@@ -3023,5 +4356,26 @@ export {
|
|
|
3023
4356
|
attachModelClass,
|
|
3024
4357
|
resolveModelClass,
|
|
3025
4358
|
TransactionContext,
|
|
3026
|
-
executeTransaction
|
|
4359
|
+
executeTransaction,
|
|
4360
|
+
maintainTrigger,
|
|
4361
|
+
isMaintainTrigger,
|
|
4362
|
+
preview,
|
|
4363
|
+
identity,
|
|
4364
|
+
LIFECYCLE_CONTRACT_MARKER,
|
|
4365
|
+
isLifecycleContract,
|
|
4366
|
+
ENTITY_WRITES_MARKER,
|
|
4367
|
+
isEntityWritesDefinition,
|
|
4368
|
+
entityWrites,
|
|
4369
|
+
getEntityWrites,
|
|
4370
|
+
lifecyclePhaseForIntent,
|
|
4371
|
+
whenMember,
|
|
4372
|
+
ViewRegistry,
|
|
4373
|
+
isViewDefinition,
|
|
4374
|
+
defineView,
|
|
4375
|
+
defineViews,
|
|
4376
|
+
defineVersioned,
|
|
4377
|
+
isProjectionDefinition,
|
|
4378
|
+
defineProjection,
|
|
4379
|
+
defineProjections,
|
|
4380
|
+
buildMaintenanceGraph
|
|
3027
4381
|
};
|