graphddb 0.2.5 → 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 CHANGED
@@ -657,7 +657,12 @@ Relation traversal は、Entity 上に定義されたアクセスパスのみを
657
657
  (`entityWrites`: 参照整合性、一意性、edge effect、派生カウンタ、outbox event、冪等性)を
658
658
  一度宣言すると、`mutation` が1つ以上の write fragment を合成し、コンパイラがそれらを単一の
659
659
  atomic な `TransactWriteItems` にマージする。すべての書き込みパスは1つの共有された
660
- `commitTransaction` オーケストレーションを経由する。
660
+ `commitTransaction` オーケストレーションを経由する。**リレーション維持アクセスパス**
661
+ (Epic #118)はこれを拡張する: リレーションの `write.maintainedOn` やスカラ `@aggregate`
662
+ フィールドが、source エンティティのライフサイクルに同期して *別の* 行を維持する —— 射影された
663
+ **snapshot**(`SET`)、上限付き **collection**(`list_append`)、スカラ **counter**
664
+ (`@aggregate` `count()` → atomic `ADD ±1`)—— を source write と同一の atomic transaction に
665
+ 合成する([aggregate-counter](./examples/aggregate-counter/) 例を参照)。
661
666
  - **[Opt-in class hydration](./docs/class-hydration.md)** —— `options.hydrate` ファクトリを
662
667
  渡すと、read 結果をデフォルトの Typed Plain Object ではなく、ホスト言語のドメインオブジェクトに
663
668
  ロードできる。ホスト専用で、bridge SSoT にシリアライズされることはない。Phase 1
@@ -689,6 +694,8 @@ Relation traversal は、Entity 上に定義されたアクセスパスのみを
689
694
  |---------|-------------|
690
695
  | [user-permissions](./examples/user-permissions/) | Single Table Design でユーザー・グループ・パーミッションを管理する。隣接リスト、転置インデックス、relation traversal、`explain` を含む。 |
691
696
  | [aggregate-tree-pattern](./examples/aggregate-tree-pattern/) | CDC emulator で駆動される差分的なツリー集計(`siteScoreAverage`): dirty 伝播、throttle 付き sweep、再計算。 |
697
+ | [embedded-snapshot-pattern](./examples/embedded-snapshot-pattern/) | リレーションを **維持アクセスパス** として扱う(Epic #118): `pattern: 'embeddedSnapshot'` のリレーションが、source write と同一の atomic transaction で、非正規化された collection / snapshot を owner 行に同期維持する。 |
698
+ | [aggregate-counter](./examples/aggregate-counter/) | RFC §5.2 の `@aggregate` **counter**: `ThreadPost.created`/`removed` のライフサイクルが、source write と同一の `TransactWriteItems` 内で atomic な `ADD ±1` により `ThreadCounter.postCount` を同期維持する。 |
692
699
 
693
700
  ## ✨ Features
694
701
 
@@ -703,6 +710,7 @@ Relation traversal は、Entity 上に定義されたアクセスパスのみを
703
710
  - 構造化 segment キー(`k` タグ): partial key は segment 境界で `begins_with` にコンパイルされる
704
711
  - 説明可能な実行計画
705
712
  - Relation(hasMany, belongsTo, hasOne, depth 制限, traversal)
713
+ - 維持アクセスパス(Epic #118): `pattern: 'embeddedSnapshot'` のリレーションが非正規化された collection / 単一行 snapshot を owner 行に同期維持し、`@aggregate` `count()` カウンタが atomic な `ADD ±1` で同期する。いずれも source write の atomic transaction に合成される(`maintainedOn` cross-entity トリガ、関数形 `projection`)。同期(`updateMode: 'mutation'`)の維持は TS / Python 両方で動作(conformance 検証済み)。stream / trim / `max()` / `materializedView` / `sparseView` は後続フェーズ
706
714
  - BatchGet 最適化
707
715
  - 並列 relation 実行(独立したサブクエリ、BatchGet チャンク、ネストした解決を上限付きの
708
716
  並列度で同時にディスパッチ)
@@ -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, err) {
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, err);
2322
+ const recovered = await onError(ctx, err3);
1866
2323
  if (recovered !== void 0) return recovered;
1867
2324
  }
1868
- throw err;
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 (err) {
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, err);
2369
+ const recovered = await onError(ctx, err3);
1913
2370
  if (recovered !== void 0) return recovered;
1914
2371
  }
1915
- throw err;
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, err) {
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, err);
2440
+ const recovered = await onError(ctx, err3);
1984
2441
  if (recovered !== void 0) return recovered;
1985
2442
  }
1986
- throw err;
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 (err) {
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, err);
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 err;
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 (err) {
2250
- await mw.runWriteError(ctx, err);
2706
+ } catch (err3) {
2707
+ await mw.runWriteError(ctx, err3);
2251
2708
  }
2252
2709
  }
2253
2710
  async function persistRewrittenWrite(modelClass, ctx, mw) {