graphddb 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -680,7 +680,7 @@ Relation traversal は、Entity 上に定義されたアクセスパスのみを
680
680
  | ドキュメント | 内容 |
681
681
  |----------|-------------|
682
682
  | [Specification](./docs/spec.md) | コア API: entity、structured keys/GSIs、query/filter、relation、batch/transaction、design rule、runtime の挙動。 |
683
- | [Design patterns](./docs/design-patterns.md) | DynamoDB の代表的な 10 設計パターン(RFC #118 §1)を graphddb の機能にマッピング: same-partition / embedded snapshot / edge / materialized view / sparse view / aggregate counter / versioned / reverse lookup / external projection。各パターンの用途・宣言方法(`pattern` / `defineView` / `defineVersioned` / `defineProjection` / `@aggregate`)・read & write 維持の挙動・Phase 別の対応状況。 |
683
+ | [Design patterns](./docs/design-patterns.md) | DynamoDB の代表的な 10 設計パターン(RFC #118 §1)を graphddb の機能にマッピング: same-partition / embedded snapshot / edge / materialized view / sparse view / aggregate counter / versioned / reverse lookup / external projection。各パターンの用途・宣言方法(`pattern` / `@model({ kind })` + `@maintainedFrom` / `@hasOne`・`@hasMany` の versioned pattern / `@aggregate`)・read & write 維持の挙動・Phase 別の対応状況。 |
684
684
  | [CQRS contract layer](./docs/cqrs-contract.md) | public な Query/Command contract、cardinality matrix、N+1 安全性、contract をまたぐ合成、context 境界。 |
685
685
  | [Mutation → command derivation](./docs/mutation-command-derivation.md) | Command IF の背後にある内部的な write-plan 合成 DSL: モデルの write セマンティクス(`entityWrites`)、fragment 合成、atomic な `TransactWriteItems` の導出。 |
686
686
  | [Class hydration](./docs/class-hydration.md) | read 結果をホスト言語のオブジェクトにロードする opt-in な `options.hydrate` ファクトリ(Phase 1: `query` トップレベル; Phase 2–3 は今後)。 |
@@ -167,6 +167,7 @@ var requireLimitRule = {
167
167
  const results = [];
168
168
  for (const relation of metadata.relations) {
169
169
  if (relation.type !== "hasMany") continue;
170
+ if (relation.options?.versioned) continue;
170
171
  const limit = relation.options?.limit;
171
172
  if (!limit || limit.default === void 0 || limit.max === void 0) {
172
173
  const kb = Object.keys(relation.keyBinding).join(", ");
@@ -3453,11 +3454,14 @@ function makeProjectionTransform(path, op, args) {
3453
3454
  `transform: unknown projection op ${JSON.stringify(op)} (expected \`identity\` or \`preview\`).`
3454
3455
  );
3455
3456
  }
3457
+ function pathOf(value) {
3458
+ return typeof value === "string" ? value : value.path;
3459
+ }
3456
3460
  function preview(path, n) {
3457
- return makeProjectionTransform(path, "preview", [n]);
3461
+ return makeProjectionTransform(pathOf(path), "preview", [n]);
3458
3462
  }
3459
3463
  function identity(path) {
3460
- return makeProjectionTransform(path, "identity", []);
3464
+ return makeProjectionTransform(pathOf(path), "identity", []);
3461
3465
  }
3462
3466
  var LIFECYCLE_CONTRACT_MARKER = /* @__PURE__ */ Symbol(
3463
3467
  "graphddb:lifecycleContract"
@@ -3549,251 +3553,208 @@ function lifecyclePhaseForIntent(intent) {
3549
3553
  return intent;
3550
3554
  }
3551
3555
 
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 };
3556
+ // src/relation/maintenance-view-adapter.ts
3557
+ function logicalNameOf(meta) {
3558
+ return meta.prefix.endsWith("#") ? meta.prefix.slice(0, -1) : meta.prefix;
3562
3559
  }
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;
3560
+ function resolveClass(factory) {
3561
+ return factory();
3588
3562
  }
3589
- function resolveSourceClass(factory) {
3590
- const resolved = factory();
3591
- try {
3592
- return resolveModelClass(resolved);
3593
- } catch {
3594
- return resolved;
3595
- }
3563
+ function sourceLogicalName(sourceClass) {
3564
+ const meta = MetadataRegistry.get(sourceClass);
3565
+ return logicalNameOf(meta);
3596
3566
  }
3597
- function isPathRooted(value) {
3598
- return value.startsWith("$.input.") || value.startsWith("$.entity.");
3567
+ function triggersFor(sourceLogical, on) {
3568
+ return on.map((ev) => `${sourceLogical}.${ev}`);
3599
3569
  }
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;
3570
+ function assertNoCrossSliceCollision(viewName, decls) {
3571
+ const projectionOwner = /* @__PURE__ */ new Map();
3572
+ const collectionOwner = /* @__PURE__ */ new Map();
3573
+ let keyFieldShape;
3574
+ decls.forEach((d, idx) => {
3575
+ for (const attr of Object.keys(d.project)) {
3576
+ const prev = projectionOwner.get(attr);
3577
+ if (prev !== void 0 && prev !== idx) {
3578
+ throw new Error(
3579
+ `View '${viewName}': projection target '${attr}' is maintained by more than one \`@maintainedFrom\` declaration. A view field must be filled by exactly one source \u2014 the declaration order carries no meaning, so this is ambiguous. Remove '${attr}' from one of the declarations (or split it onto a distinct view field).`
3580
+ );
3581
+ }
3582
+ projectionOwner.set(attr, idx);
3614
3583
  }
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
- );
3584
+ if (d.collectionField !== void 0) {
3585
+ const prev = collectionOwner.get(d.collectionField);
3586
+ if (prev !== void 0 && prev !== idx) {
3587
+ throw new Error(
3588
+ `View '${viewName}': collection field '${d.collectionField}' is maintained by more than one \`@maintainedFrom\` declaration. A maintained collection has exactly one source \u2014 split the second source onto a distinct collection field.`
3589
+ );
3590
+ }
3591
+ collectionOwner.set(d.collectionField, idx);
3651
3592
  }
3652
- if (!src.maintainedOn || src.maintainedOn.length === 0) {
3593
+ const shape = Object.keys(d.keyBind).slice().sort().join(",");
3594
+ if (keyFieldShape === void 0) {
3595
+ keyFieldShape = shape;
3596
+ } else if (keyFieldShape !== shape) {
3653
3597
  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']\`).`
3598
+ `View '${viewName}': \`@maintainedFrom\` declarations bind different view-key field SETS ([${keyFieldShape}] vs [${shape}]). All sources must resolve the SAME view row identity \u2014 they must bind the same set of view-key fields (the source field a given key reads from may differ per source, but the key SHAPE may not). A divergent key shape would split the row.`
3655
3599
  );
3656
3600
  }
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) {
3601
+ });
3602
+ }
3603
+ function viewFromMaintainedFrom(viewClass, meta, kind) {
3604
+ const viewName = viewClass.name ?? logicalNameOf(meta);
3605
+ const decls = meta.maintainedFrom ?? [];
3606
+ const pattern = kind === "sparseView" ? "sparseView" : "materializedView";
3607
+ for (const d of decls) {
3608
+ if (kind === "sparseView" && !d.when) {
3669
3609
  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.`
3610
+ `View '${viewName}': \`kind: 'sparseView'\` requires every \`@maintainedFrom\` to declare a \`when\` membership predicate (e.g. \`whenMember(source.status, 'eq', 'active')\`) \u2014 the predicate decides PUT (holds) vs DELETE (flips false).`
3671
3611
  );
3672
3612
  }
3673
- if (pattern === "sparseView" && !predicate) {
3613
+ if (kind !== "sparseView" && d.when) {
3674
3614
  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.`
3615
+ `View '${viewName}': a \`@maintainedFrom\` declares a \`when\` membership predicate but the view \`kind\` is '${kind}'. Declare \`kind: 'sparseView'\` to maintain a predicate-gated put/delete row.`
3676
3616
  );
3677
3617
  }
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
- );
3618
+ }
3619
+ assertNoCrossSliceCollision(
3620
+ viewName,
3621
+ decls.map((d) => ({
3622
+ keyBind: d.keyBind,
3623
+ project: d.project,
3624
+ collectionField: d.collection?.field
3625
+ }))
3626
+ );
3627
+ let updateMode;
3628
+ let consistency;
3629
+ for (const d of decls) {
3630
+ if (d.updateMode !== void 0) {
3631
+ if (updateMode !== void 0 && updateMode !== d.updateMode) {
3632
+ throw new Error(
3633
+ `View '${viewName}': conflicting \`updateMode\` across \`@maintainedFrom\` declarations ('${updateMode}' vs '${d.updateMode}'). A view row has a single write path \u2014 declare the same \`updateMode\` on every source.`
3634
+ );
3635
+ }
3636
+ updateMode = d.updateMode;
3637
+ }
3638
+ if (d.consistency !== void 0) {
3639
+ if (consistency !== void 0 && consistency !== d.consistency) {
3640
+ throw new Error(
3641
+ `View '${viewName}': conflicting \`consistency\` across \`@maintainedFrom\` declarations ('${consistency}' vs '${d.consistency}').`
3642
+ );
3643
+ }
3644
+ consistency = d.consistency;
3682
3645
  }
3683
- slices.push({
3684
- sourceClass: resolveSourceClass(src.entity),
3685
- maintainedOn,
3686
- keys,
3687
- project,
3688
- ...collection ? { collection } : {},
3689
- ...predicate ? { predicate } : {}
3690
- });
3691
3646
  }
3692
- const def = {
3693
- [VIEW_DEFINITION_MARKER]: true,
3694
- name,
3647
+ const slices = decls.map((d) => sliceFromDeclaration(d));
3648
+ return {
3649
+ name: viewName,
3695
3650
  viewClass,
3696
3651
  pattern,
3697
3652
  slices,
3698
- ...input.write?.updateMode !== void 0 ? { updateMode: input.write.updateMode } : {},
3699
- ...input.write?.consistency !== void 0 ? { consistency: input.write.consistency } : {}
3653
+ ...updateMode !== void 0 ? { updateMode } : {},
3654
+ ...consistency !== void 0 ? { consistency } : {}
3700
3655
  };
3701
- ViewRegistry.register(def);
3702
- return def;
3703
3656
  }
3704
- function defineViews(definitions) {
3705
- for (const [name, def] of Object.entries(definitions)) {
3706
- if (!isViewDefinition(def)) {
3657
+ function sliceFromDeclaration(d) {
3658
+ const sourceClass = resolveClass(d.sourceFactory);
3659
+ const sourceLogical = sourceLogicalName(sourceClass);
3660
+ const collection = d.collection ? {
3661
+ field: d.collection.field,
3662
+ ...d.collection.maxItems !== void 0 ? { maxItems: d.collection.maxItems } : {},
3663
+ ...d.collection.orderBy !== void 0 ? { orderBy: d.collection.orderBy, orderDir: d.collection.order ?? "DESC" } : {}
3664
+ } : void 0;
3665
+ return {
3666
+ sourceClass,
3667
+ maintainedOn: triggersFor(sourceLogical, d.on),
3668
+ keys: d.keyBind,
3669
+ project: d.project,
3670
+ ...collection ? { collection } : {},
3671
+ ...d.when ? { predicate: d.when } : {}
3672
+ };
3673
+ }
3674
+ function versionedViews(ownerClass, ownerMeta) {
3675
+ const ownerLogical = logicalNameOf(ownerMeta);
3676
+ const rels = [];
3677
+ for (const rel of ownerMeta.relations) {
3678
+ const v = rel.options?.versioned;
3679
+ if (!v) continue;
3680
+ const targetClass = resolveClass(rel.targetFactory);
3681
+ const targetMeta = MetadataRegistry.get(targetClass);
3682
+ if (!targetMeta.primaryKey) {
3707
3683
  throw new Error(
3708
- `defineViews: '${name}' is not a view definition. Use defineView(name, { pattern: 'materializedView', source, keys, fields }) to build entries.`
3684
+ `Versioned relation '${rel.propertyName}' on '${ownerLogical}': the target model '${targetClass.name}' has no \`key\` \u2014 a versioned latest/history row needs a key to resolve which row to overwrite / append.`
3709
3685
  );
3710
3686
  }
3687
+ const keyFields = [
3688
+ ...segmentFieldNames(targetMeta.primaryKey.segmented.pkSegments),
3689
+ ...segmentFieldNames(targetMeta.primaryKey.segmented.skSegments)
3690
+ ];
3691
+ rels.push({
3692
+ propertyName: rel.propertyName,
3693
+ mode: v.mode,
3694
+ on: v.on,
3695
+ project: v.project,
3696
+ ...v.updateMode !== void 0 ? { updateMode: v.updateMode } : {},
3697
+ ...v.consistency !== void 0 ? { consistency: v.consistency } : {},
3698
+ targetClass,
3699
+ targetMeta,
3700
+ keyFields
3701
+ });
3711
3702
  }
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
- );
3703
+ assertVersionedDiscriminator(ownerLogical, rels);
3704
+ const out = [];
3705
+ for (const rel of rels) {
3706
+ const keys = {};
3707
+ for (const f of rel.keyFields) keys[f] = `$.entity.${f}`;
3708
+ const targetName = rel.targetClass.name ?? logicalNameOf(rel.targetMeta);
3709
+ out.push({
3710
+ name: targetName,
3711
+ viewClass: rel.targetClass,
3712
+ pattern: "materializedView",
3713
+ slices: [
3714
+ {
3715
+ sourceClass: ownerClass,
3716
+ maintainedOn: triggersFor(ownerLogical, rel.on),
3717
+ keys,
3718
+ project: rel.project
3719
+ }
3720
+ ],
3721
+ ...rel.updateMode !== void 0 ? { updateMode: rel.updateMode } : {},
3722
+ ...rel.consistency !== void 0 ? { consistency: rel.consistency } : {}
3723
+ });
3734
3724
  }
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;
3725
+ return out;
3754
3726
  }
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
- );
3727
+ function assertVersionedDiscriminator(ownerLogical, rels) {
3728
+ const latests = rels.filter((r) => r.mode === "latest");
3729
+ const histories = rels.filter((r) => r.mode === "history");
3730
+ if (latests.length === 0 || histories.length === 0) return;
3731
+ for (const latest of latests) {
3732
+ const latestKeyFields = new Set(latest.keyFields);
3733
+ for (const history of histories) {
3734
+ const discriminators = history.keyFields.filter((f) => !latestKeyFields.has(f));
3735
+ if (discriminators.length === 0) {
3736
+ const latestName = latest.targetClass.name ?? "<latest>";
3737
+ const historyName = history.targetClass.name ?? "<history>";
3738
+ throw new Error(
3739
+ `Versioned relation pair on '${ownerLogical}': the \`versionedHistory\` target '${historyName}' (\`${history.propertyName}\`) is keyed by [${history.keyFields.map((f) => `'${f}'`).join(", ")}], which carries NO version discriminator beyond the \`versionedLatest\` target '${latestName}' (\`${latest.propertyName}\`) keyed by [${[...latestKeyFields].map((f) => `'${f}'`).join(", ")}]. The history row must be keyed by a per-version field (e.g. add a \`version\` segment to '${historyName}'s \`@key\`) so each revision is a NEW row; otherwise every revision overwrites one row (a latest pointer, not an append-only history).`
3740
+ );
3741
+ }
3742
+ }
3774
3743
  }
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
3744
  }
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
- );
3745
+ function collectViewDefinitions(registry) {
3746
+ const resolved = registry ?? MetadataRegistry.getAll();
3747
+ const out = [];
3748
+ for (const [klass, meta] of resolved) {
3749
+ const kind = meta.kind ?? "entity";
3750
+ if (kind === "materializedView" || kind === "sparseView") {
3751
+ out.push(viewFromMaintainedFrom(klass, meta, kind));
3752
+ }
3753
+ if (meta.relations?.some((r) => r.options?.versioned)) {
3754
+ out.push(...versionedViews(klass, meta));
3794
3755
  }
3795
3756
  }
3796
- return definitions;
3757
+ return out;
3797
3758
  }
3798
3759
 
3799
3760
  // src/relation/maintenance-graph.ts
@@ -3803,7 +3764,7 @@ var COUNT_DELTA_FOR_EVENT = {
3803
3764
  updated: 0
3804
3765
  };
3805
3766
  var MAINTAIN_EVENTS = ["created", "updated", "removed"];
3806
- function isPathRooted2(value) {
3767
+ function isPathRooted(value) {
3807
3768
  return value.startsWith("$.input.") || value.startsWith("$.entity.");
3808
3769
  }
3809
3770
  function sourceAttributeOf(path) {
@@ -3812,7 +3773,7 @@ function sourceAttributeOf(path) {
3812
3773
  }
3813
3774
  function normalizeProjectionEntry(value) {
3814
3775
  if (typeof value === "string") {
3815
- const path = isPathRooted2(value) ? value : `$.entity.${value}`;
3776
+ const path = isPathRooted(value) ? value : `$.entity.${value}`;
3816
3777
  return identity(path);
3817
3778
  }
3818
3779
  return value;
@@ -3879,7 +3840,7 @@ function assertMaintenanceRoundTrips(args) {
3879
3840
  }
3880
3841
  }
3881
3842
  for (const [attr, transform] of Object.entries(projection)) {
3882
- if (!isPathRooted2(transform.path)) {
3843
+ if (!isPathRooted(transform.path)) {
3883
3844
  throw new Error(
3884
3845
  `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
3846
  );
@@ -3926,12 +3887,12 @@ function assertCounterRoundTrips(args) {
3926
3887
  }
3927
3888
  }
3928
3889
  }
3929
- function logicalNameOf(meta) {
3890
+ function logicalNameOf2(meta) {
3930
3891
  return meta.prefix.endsWith("#") ? meta.prefix.slice(0, -1) : meta.prefix;
3931
3892
  }
3932
3893
  function resolveEntityByName(name, registry) {
3933
3894
  for (const [klass, meta] of registry) {
3934
- if (logicalNameOf(meta) === name) return klass;
3895
+ if (logicalNameOf2(meta) === name) return klass;
3935
3896
  }
3936
3897
  return void 0;
3937
3898
  }
@@ -3999,12 +3960,12 @@ function resolveCollectionOrder(relation, sourceMeta, ownerEntity, project) {
3999
3960
  const orderField = skFields[skFields.length - 1];
4000
3961
  if (orderField === void 0) {
4001
3962
  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\`.`
3963
+ `Relation '${relation.propertyName}' on '${ownerEntity}' declares \`read.order: '${order}'\` for its maintained collection, but its source entity '${logicalNameOf2(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 '${logicalNameOf2(sourceMeta)}' or drop \`read.order\`.`
4003
3964
  );
4004
3965
  }
4005
3966
  if (!Object.prototype.hasOwnProperty.call(project, orderField)) {
4006
3967
  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\`.`
3968
+ `Relation '${relation.propertyName}' on '${ownerEntity}' declares \`read.order: '${order}'\` for its maintained collection, which orders by the source entity '${logicalNameOf2(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
3969
  );
4009
3970
  }
4010
3971
  return { orderBy: `$.entity.${orderField}`, orderDir: order };
@@ -4075,16 +4036,15 @@ function assertNoCycle(items) {
4075
4036
  }
4076
4037
  }
4077
4038
  function buildMaintenanceGraph(registry, views) {
4078
- const useGlobal = registry === void 0;
4079
4039
  const resolvedRegistry = registry ?? MetadataRegistry.getAll();
4080
- const resolvedViews = views ?? (useGlobal ? ViewRegistry.getAll() : []);
4040
+ const resolvedViews = views ?? collectViewDefinitions(resolvedRegistry);
4081
4041
  return buildMaintenanceGraphImpl(resolvedRegistry, resolvedViews);
4082
4042
  }
4083
4043
  function buildMaintenanceGraphImpl(registry, views) {
4084
4044
  const items = [];
4085
4045
  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
4046
  for (const { klass: ownerClass, meta: ownerMeta } of owners) {
4087
- const ownerEntity = logicalNameOf(ownerMeta);
4047
+ const ownerEntity = logicalNameOf2(ownerMeta);
4088
4048
  const relations = [...ownerMeta.relations].sort(
4089
4049
  (a, b) => a.propertyName < b.propertyName ? -1 : a.propertyName > b.propertyName ? 1 : 0
4090
4050
  );
@@ -4093,7 +4053,7 @@ function buildMaintenanceGraphImpl(registry, views) {
4093
4053
  if (!maintainedOn || maintainedOn.length === 0) continue;
4094
4054
  const sourceClass = relation.targetFactory();
4095
4055
  const sourceMeta = MetadataRegistry.get(sourceClass);
4096
- const relationTargetEntity = logicalNameOf(sourceMeta);
4056
+ const relationTargetEntity = logicalNameOf2(sourceMeta);
4097
4057
  const project = buildProjectionMap(
4098
4058
  relation.options?.projection,
4099
4059
  ownerEntity,
@@ -4159,7 +4119,7 @@ function buildMaintenanceGraphImpl(registry, views) {
4159
4119
  if (!maintainedOn || maintainedOn.length === 0) continue;
4160
4120
  const sourceClass = agg.targetFactory();
4161
4121
  const sourceMeta = MetadataRegistry.get(sourceClass);
4162
- const aggSourceEntity = logicalNameOf(sourceMeta);
4122
+ const aggSourceEntity = logicalNameOf2(sourceMeta);
4163
4123
  const { keys, sourceFields } = buildDestinationKeys(agg.keyBinding);
4164
4124
  const destinationKeyFields = Object.keys(keys);
4165
4125
  const valueSourceFields = agg.options.value.op === "max" ? [agg.options.value.field] : [];
@@ -4216,11 +4176,11 @@ function buildMaintenanceGraphImpl(registry, views) {
4216
4176
  for (const view of [...views].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) {
4217
4177
  const ownerClass = view.viewClass;
4218
4178
  const ownerMeta = MetadataRegistry.get(ownerClass);
4219
- const ownerEntity = logicalNameOf(ownerMeta);
4179
+ const ownerEntity = logicalNameOf2(ownerMeta);
4220
4180
  view.slices.forEach((slice, sliceIndex) => {
4221
4181
  const sourceClass = slice.sourceClass;
4222
4182
  const sourceMeta = MetadataRegistry.get(sourceClass);
4223
- const sourceEntity = logicalNameOf(sourceMeta);
4183
+ const sourceEntity = logicalNameOf2(sourceMeta);
4224
4184
  const keys = slice.keys;
4225
4185
  const destinationKeyFields = Object.keys(keys);
4226
4186
  const keyBindingSourceFields = Object.values(keys).map((p) => sourceAttributeOf(p));
@@ -4242,7 +4202,7 @@ function buildMaintenanceGraphImpl(registry, views) {
4242
4202
  projection: slice.project
4243
4203
  });
4244
4204
  if (slice.predicate) {
4245
- if (!isPathRooted2(slice.predicate.path)) {
4205
+ if (!isPathRooted(slice.predicate.path)) {
4246
4206
  throw new Error(
4247
4207
  `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
4208
  );
@@ -4368,14 +4328,6 @@ export {
4368
4328
  entityWrites,
4369
4329
  getEntityWrites,
4370
4330
  lifecyclePhaseForIntent,
4371
- whenMember,
4372
- ViewRegistry,
4373
- isViewDefinition,
4374
- defineView,
4375
- defineViews,
4376
- defineVersioned,
4377
- isProjectionDefinition,
4378
- defineProjection,
4379
- defineProjections,
4331
+ collectViewDefinitions,
4380
4332
  buildMaintenanceGraph
4381
4333
  };