graphddb 0.3.1 → 0.4.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.
@@ -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(", ");
@@ -983,30 +984,46 @@ var TableMapping = class {
983
984
  };
984
985
 
985
986
  // src/define/param.ts
986
- function makeParam(kind, literals) {
987
- return literals === void 0 ? { kind } : { kind, literals };
987
+ function isParamOptions(value) {
988
+ return typeof value === "object" && value !== null;
989
+ }
990
+ function makeParam(kind, literals, description) {
991
+ const param2 = { kind };
992
+ if (literals !== void 0) param2.literals = literals;
993
+ if (description !== void 0) param2.description = description;
994
+ return param2;
988
995
  }
989
996
  var param = {
990
997
  /** A placeholder for a `string` value. */
991
- string() {
992
- return makeParam("string");
998
+ string(options) {
999
+ return makeParam("string", void 0, options?.description);
993
1000
  },
994
1001
  /** A placeholder for a `number` value. */
995
- number() {
996
- return makeParam("number");
1002
+ number(options) {
1003
+ return makeParam("number", void 0, options?.description);
997
1004
  },
998
1005
  /**
999
1006
  * A placeholder constrained to one of the given literal values. The value
1000
1007
  * type of the resulting {@link Param} is the **union of the literals**, so it
1001
- * is preserved precisely in the IR and the inferred definition types.
1008
+ * is preserved precisely in the IR and the inferred definition types. A final
1009
+ * plain-object argument is read as {@link ParamOptions} (a `description`),
1010
+ * distinguished from the `string` / `number` literal values.
1002
1011
  *
1003
1012
  * @example
1004
1013
  * ```ts
1005
1014
  * param.literal('active', 'disabled'); // Param<'active' | 'disabled'>
1015
+ * param.literal('active', 'disabled', { description: 'Account state.' });
1006
1016
  * ```
1007
1017
  */
1008
- literal(...values) {
1009
- return makeParam("literal", values);
1018
+ literal(...valuesAndOptions) {
1019
+ const args = [...valuesAndOptions];
1020
+ const last = args[args.length - 1];
1021
+ const options = isParamOptions(last) ? args.pop() : void 0;
1022
+ return makeParam(
1023
+ "literal",
1024
+ args,
1025
+ options?.description
1026
+ );
1010
1027
  },
1011
1028
  /**
1012
1029
  * A placeholder for an **array** parameter whose elements have the given field
@@ -1019,8 +1036,10 @@ var param = {
1019
1036
  * // Param<{ userId: string; role: string }[]>
1020
1037
  * ```
1021
1038
  */
1022
- array(element) {
1023
- return { kind: "array", element };
1039
+ array(element, options) {
1040
+ const param2 = { kind: "array", element };
1041
+ if (options?.description !== void 0) param2.description = options.description;
1042
+ return param2;
1024
1043
  }
1025
1044
  };
1026
1045
  function isParam(value) {
@@ -3453,11 +3472,14 @@ function makeProjectionTransform(path, op, args) {
3453
3472
  `transform: unknown projection op ${JSON.stringify(op)} (expected \`identity\` or \`preview\`).`
3454
3473
  );
3455
3474
  }
3475
+ function pathOf(value) {
3476
+ return typeof value === "string" ? value : value.path;
3477
+ }
3456
3478
  function preview(path, n) {
3457
- return makeProjectionTransform(path, "preview", [n]);
3479
+ return makeProjectionTransform(pathOf(path), "preview", [n]);
3458
3480
  }
3459
3481
  function identity(path) {
3460
- return makeProjectionTransform(path, "identity", []);
3482
+ return makeProjectionTransform(pathOf(path), "identity", []);
3461
3483
  }
3462
3484
  var LIFECYCLE_CONTRACT_MARKER = /* @__PURE__ */ Symbol(
3463
3485
  "graphddb:lifecycleContract"
@@ -3549,251 +3571,208 @@ function lifecyclePhaseForIntent(intent) {
3549
3571
  return intent;
3550
3572
  }
3551
3573
 
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 };
3574
+ // src/relation/maintenance-view-adapter.ts
3575
+ function logicalNameOf(meta) {
3576
+ return meta.prefix.endsWith("#") ? meta.prefix.slice(0, -1) : meta.prefix;
3562
3577
  }
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;
3578
+ function resolveClass(factory) {
3579
+ return factory();
3588
3580
  }
3589
- function resolveSourceClass(factory) {
3590
- const resolved = factory();
3591
- try {
3592
- return resolveModelClass(resolved);
3593
- } catch {
3594
- return resolved;
3595
- }
3581
+ function sourceLogicalName(sourceClass) {
3582
+ const meta = MetadataRegistry.get(sourceClass);
3583
+ return logicalNameOf(meta);
3596
3584
  }
3597
- function isPathRooted(value) {
3598
- return value.startsWith("$.input.") || value.startsWith("$.entity.");
3585
+ function triggersFor(sourceLogical, on) {
3586
+ return on.map((ev) => `${sourceLogical}.${ev}`);
3599
3587
  }
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;
3588
+ function assertNoCrossSliceCollision(viewName, decls) {
3589
+ const projectionOwner = /* @__PURE__ */ new Map();
3590
+ const collectionOwner = /* @__PURE__ */ new Map();
3591
+ let keyFieldShape;
3592
+ decls.forEach((d, idx) => {
3593
+ for (const attr of Object.keys(d.project)) {
3594
+ const prev = projectionOwner.get(attr);
3595
+ if (prev !== void 0 && prev !== idx) {
3596
+ throw new Error(
3597
+ `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).`
3598
+ );
3599
+ }
3600
+ projectionOwner.set(attr, idx);
3614
3601
  }
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
- );
3602
+ if (d.collectionField !== void 0) {
3603
+ const prev = collectionOwner.get(d.collectionField);
3604
+ if (prev !== void 0 && prev !== idx) {
3605
+ throw new Error(
3606
+ `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.`
3607
+ );
3608
+ }
3609
+ collectionOwner.set(d.collectionField, idx);
3651
3610
  }
3652
- if (!src.maintainedOn || src.maintainedOn.length === 0) {
3611
+ const shape = Object.keys(d.keyBind).slice().sort().join(",");
3612
+ if (keyFieldShape === void 0) {
3613
+ keyFieldShape = shape;
3614
+ } else if (keyFieldShape !== shape) {
3653
3615
  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']\`).`
3616
+ `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
3617
  );
3656
3618
  }
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) {
3619
+ });
3620
+ }
3621
+ function viewFromMaintainedFrom(viewClass, meta, kind) {
3622
+ const viewName = viewClass.name ?? logicalNameOf(meta);
3623
+ const decls = meta.maintainedFrom ?? [];
3624
+ const pattern = kind === "sparseView" ? "sparseView" : "materializedView";
3625
+ for (const d of decls) {
3626
+ if (kind === "sparseView" && !d.when) {
3669
3627
  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.`
3628
+ `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
3629
  );
3672
3630
  }
3673
- if (pattern === "sparseView" && !predicate) {
3631
+ if (kind !== "sparseView" && d.when) {
3674
3632
  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.`
3633
+ `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
3634
  );
3677
3635
  }
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
- );
3636
+ }
3637
+ assertNoCrossSliceCollision(
3638
+ viewName,
3639
+ decls.map((d) => ({
3640
+ keyBind: d.keyBind,
3641
+ project: d.project,
3642
+ collectionField: d.collection?.field
3643
+ }))
3644
+ );
3645
+ let updateMode;
3646
+ let consistency;
3647
+ for (const d of decls) {
3648
+ if (d.updateMode !== void 0) {
3649
+ if (updateMode !== void 0 && updateMode !== d.updateMode) {
3650
+ throw new Error(
3651
+ `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.`
3652
+ );
3653
+ }
3654
+ updateMode = d.updateMode;
3655
+ }
3656
+ if (d.consistency !== void 0) {
3657
+ if (consistency !== void 0 && consistency !== d.consistency) {
3658
+ throw new Error(
3659
+ `View '${viewName}': conflicting \`consistency\` across \`@maintainedFrom\` declarations ('${consistency}' vs '${d.consistency}').`
3660
+ );
3661
+ }
3662
+ consistency = d.consistency;
3682
3663
  }
3683
- slices.push({
3684
- sourceClass: resolveSourceClass(src.entity),
3685
- maintainedOn,
3686
- keys,
3687
- project,
3688
- ...collection ? { collection } : {},
3689
- ...predicate ? { predicate } : {}
3690
- });
3691
3664
  }
3692
- const def = {
3693
- [VIEW_DEFINITION_MARKER]: true,
3694
- name,
3665
+ const slices = decls.map((d) => sliceFromDeclaration(d));
3666
+ return {
3667
+ name: viewName,
3695
3668
  viewClass,
3696
3669
  pattern,
3697
3670
  slices,
3698
- ...input.write?.updateMode !== void 0 ? { updateMode: input.write.updateMode } : {},
3699
- ...input.write?.consistency !== void 0 ? { consistency: input.write.consistency } : {}
3671
+ ...updateMode !== void 0 ? { updateMode } : {},
3672
+ ...consistency !== void 0 ? { consistency } : {}
3700
3673
  };
3701
- ViewRegistry.register(def);
3702
- return def;
3703
3674
  }
3704
- function defineViews(definitions) {
3705
- for (const [name, def] of Object.entries(definitions)) {
3706
- if (!isViewDefinition(def)) {
3675
+ function sliceFromDeclaration(d) {
3676
+ const sourceClass = resolveClass(d.sourceFactory);
3677
+ const sourceLogical = sourceLogicalName(sourceClass);
3678
+ const collection = d.collection ? {
3679
+ field: d.collection.field,
3680
+ ...d.collection.maxItems !== void 0 ? { maxItems: d.collection.maxItems } : {},
3681
+ ...d.collection.orderBy !== void 0 ? { orderBy: d.collection.orderBy, orderDir: d.collection.order ?? "DESC" } : {}
3682
+ } : void 0;
3683
+ return {
3684
+ sourceClass,
3685
+ maintainedOn: triggersFor(sourceLogical, d.on),
3686
+ keys: d.keyBind,
3687
+ project: d.project,
3688
+ ...collection ? { collection } : {},
3689
+ ...d.when ? { predicate: d.when } : {}
3690
+ };
3691
+ }
3692
+ function versionedViews(ownerClass, ownerMeta) {
3693
+ const ownerLogical = logicalNameOf(ownerMeta);
3694
+ const rels = [];
3695
+ for (const rel of ownerMeta.relations) {
3696
+ const v = rel.options?.versioned;
3697
+ if (!v) continue;
3698
+ const targetClass = resolveClass(rel.targetFactory);
3699
+ const targetMeta = MetadataRegistry.get(targetClass);
3700
+ if (!targetMeta.primaryKey) {
3707
3701
  throw new Error(
3708
- `defineViews: '${name}' is not a view definition. Use defineView(name, { pattern: 'materializedView', source, keys, fields }) to build entries.`
3702
+ `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
3703
  );
3710
3704
  }
3705
+ const keyFields = [
3706
+ ...segmentFieldNames(targetMeta.primaryKey.segmented.pkSegments),
3707
+ ...segmentFieldNames(targetMeta.primaryKey.segmented.skSegments)
3708
+ ];
3709
+ rels.push({
3710
+ propertyName: rel.propertyName,
3711
+ mode: v.mode,
3712
+ on: v.on,
3713
+ project: v.project,
3714
+ ...v.updateMode !== void 0 ? { updateMode: v.updateMode } : {},
3715
+ ...v.consistency !== void 0 ? { consistency: v.consistency } : {},
3716
+ targetClass,
3717
+ targetMeta,
3718
+ keyFields
3719
+ });
3711
3720
  }
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
- );
3721
+ assertVersionedDiscriminator(ownerLogical, rels);
3722
+ const out = [];
3723
+ for (const rel of rels) {
3724
+ const keys = {};
3725
+ for (const f of rel.keyFields) keys[f] = `$.entity.${f}`;
3726
+ const targetName = rel.targetClass.name ?? logicalNameOf(rel.targetMeta);
3727
+ out.push({
3728
+ name: targetName,
3729
+ viewClass: rel.targetClass,
3730
+ pattern: "materializedView",
3731
+ slices: [
3732
+ {
3733
+ sourceClass: ownerClass,
3734
+ maintainedOn: triggersFor(ownerLogical, rel.on),
3735
+ keys,
3736
+ project: rel.project
3737
+ }
3738
+ ],
3739
+ ...rel.updateMode !== void 0 ? { updateMode: rel.updateMode } : {},
3740
+ ...rel.consistency !== void 0 ? { consistency: rel.consistency } : {}
3741
+ });
3734
3742
  }
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;
3743
+ return out;
3754
3744
  }
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
- );
3745
+ function assertVersionedDiscriminator(ownerLogical, rels) {
3746
+ const latests = rels.filter((r) => r.mode === "latest");
3747
+ const histories = rels.filter((r) => r.mode === "history");
3748
+ if (latests.length === 0 || histories.length === 0) return;
3749
+ for (const latest of latests) {
3750
+ const latestKeyFields = new Set(latest.keyFields);
3751
+ for (const history of histories) {
3752
+ const discriminators = history.keyFields.filter((f) => !latestKeyFields.has(f));
3753
+ if (discriminators.length === 0) {
3754
+ const latestName = latest.targetClass.name ?? "<latest>";
3755
+ const historyName = history.targetClass.name ?? "<history>";
3756
+ throw new Error(
3757
+ `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).`
3758
+ );
3759
+ }
3760
+ }
3774
3761
  }
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
3762
  }
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
- );
3763
+ function collectViewDefinitions(registry) {
3764
+ const resolved = registry ?? MetadataRegistry.getAll();
3765
+ const out = [];
3766
+ for (const [klass, meta] of resolved) {
3767
+ const kind = meta.kind ?? "entity";
3768
+ if (kind === "materializedView" || kind === "sparseView") {
3769
+ out.push(viewFromMaintainedFrom(klass, meta, kind));
3770
+ }
3771
+ if (meta.relations?.some((r) => r.options?.versioned)) {
3772
+ out.push(...versionedViews(klass, meta));
3794
3773
  }
3795
3774
  }
3796
- return definitions;
3775
+ return out;
3797
3776
  }
3798
3777
 
3799
3778
  // src/relation/maintenance-graph.ts
@@ -3803,7 +3782,7 @@ var COUNT_DELTA_FOR_EVENT = {
3803
3782
  updated: 0
3804
3783
  };
3805
3784
  var MAINTAIN_EVENTS = ["created", "updated", "removed"];
3806
- function isPathRooted2(value) {
3785
+ function isPathRooted(value) {
3807
3786
  return value.startsWith("$.input.") || value.startsWith("$.entity.");
3808
3787
  }
3809
3788
  function sourceAttributeOf(path) {
@@ -3812,7 +3791,7 @@ function sourceAttributeOf(path) {
3812
3791
  }
3813
3792
  function normalizeProjectionEntry(value) {
3814
3793
  if (typeof value === "string") {
3815
- const path = isPathRooted2(value) ? value : `$.entity.${value}`;
3794
+ const path = isPathRooted(value) ? value : `$.entity.${value}`;
3816
3795
  return identity(path);
3817
3796
  }
3818
3797
  return value;
@@ -3879,7 +3858,7 @@ function assertMaintenanceRoundTrips(args) {
3879
3858
  }
3880
3859
  }
3881
3860
  for (const [attr, transform] of Object.entries(projection)) {
3882
- if (!isPathRooted2(transform.path)) {
3861
+ if (!isPathRooted(transform.path)) {
3883
3862
  throw new Error(
3884
3863
  `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
3864
  );
@@ -3926,12 +3905,12 @@ function assertCounterRoundTrips(args) {
3926
3905
  }
3927
3906
  }
3928
3907
  }
3929
- function logicalNameOf(meta) {
3908
+ function logicalNameOf2(meta) {
3930
3909
  return meta.prefix.endsWith("#") ? meta.prefix.slice(0, -1) : meta.prefix;
3931
3910
  }
3932
3911
  function resolveEntityByName(name, registry) {
3933
3912
  for (const [klass, meta] of registry) {
3934
- if (logicalNameOf(meta) === name) return klass;
3913
+ if (logicalNameOf2(meta) === name) return klass;
3935
3914
  }
3936
3915
  return void 0;
3937
3916
  }
@@ -3999,12 +3978,12 @@ function resolveCollectionOrder(relation, sourceMeta, ownerEntity, project) {
3999
3978
  const orderField = skFields[skFields.length - 1];
4000
3979
  if (orderField === void 0) {
4001
3980
  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\`.`
3981
+ `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
3982
  );
4004
3983
  }
4005
3984
  if (!Object.prototype.hasOwnProperty.call(project, orderField)) {
4006
3985
  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\`.`
3986
+ `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
3987
  );
4009
3988
  }
4010
3989
  return { orderBy: `$.entity.${orderField}`, orderDir: order };
@@ -4075,16 +4054,15 @@ function assertNoCycle(items) {
4075
4054
  }
4076
4055
  }
4077
4056
  function buildMaintenanceGraph(registry, views) {
4078
- const useGlobal = registry === void 0;
4079
4057
  const resolvedRegistry = registry ?? MetadataRegistry.getAll();
4080
- const resolvedViews = views ?? (useGlobal ? ViewRegistry.getAll() : []);
4058
+ const resolvedViews = views ?? collectViewDefinitions(resolvedRegistry);
4081
4059
  return buildMaintenanceGraphImpl(resolvedRegistry, resolvedViews);
4082
4060
  }
4083
4061
  function buildMaintenanceGraphImpl(registry, views) {
4084
4062
  const items = [];
4085
4063
  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
4064
  for (const { klass: ownerClass, meta: ownerMeta } of owners) {
4087
- const ownerEntity = logicalNameOf(ownerMeta);
4065
+ const ownerEntity = logicalNameOf2(ownerMeta);
4088
4066
  const relations = [...ownerMeta.relations].sort(
4089
4067
  (a, b) => a.propertyName < b.propertyName ? -1 : a.propertyName > b.propertyName ? 1 : 0
4090
4068
  );
@@ -4093,7 +4071,7 @@ function buildMaintenanceGraphImpl(registry, views) {
4093
4071
  if (!maintainedOn || maintainedOn.length === 0) continue;
4094
4072
  const sourceClass = relation.targetFactory();
4095
4073
  const sourceMeta = MetadataRegistry.get(sourceClass);
4096
- const relationTargetEntity = logicalNameOf(sourceMeta);
4074
+ const relationTargetEntity = logicalNameOf2(sourceMeta);
4097
4075
  const project = buildProjectionMap(
4098
4076
  relation.options?.projection,
4099
4077
  ownerEntity,
@@ -4159,7 +4137,7 @@ function buildMaintenanceGraphImpl(registry, views) {
4159
4137
  if (!maintainedOn || maintainedOn.length === 0) continue;
4160
4138
  const sourceClass = agg.targetFactory();
4161
4139
  const sourceMeta = MetadataRegistry.get(sourceClass);
4162
- const aggSourceEntity = logicalNameOf(sourceMeta);
4140
+ const aggSourceEntity = logicalNameOf2(sourceMeta);
4163
4141
  const { keys, sourceFields } = buildDestinationKeys(agg.keyBinding);
4164
4142
  const destinationKeyFields = Object.keys(keys);
4165
4143
  const valueSourceFields = agg.options.value.op === "max" ? [agg.options.value.field] : [];
@@ -4216,11 +4194,11 @@ function buildMaintenanceGraphImpl(registry, views) {
4216
4194
  for (const view of [...views].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) {
4217
4195
  const ownerClass = view.viewClass;
4218
4196
  const ownerMeta = MetadataRegistry.get(ownerClass);
4219
- const ownerEntity = logicalNameOf(ownerMeta);
4197
+ const ownerEntity = logicalNameOf2(ownerMeta);
4220
4198
  view.slices.forEach((slice, sliceIndex) => {
4221
4199
  const sourceClass = slice.sourceClass;
4222
4200
  const sourceMeta = MetadataRegistry.get(sourceClass);
4223
- const sourceEntity = logicalNameOf(sourceMeta);
4201
+ const sourceEntity = logicalNameOf2(sourceMeta);
4224
4202
  const keys = slice.keys;
4225
4203
  const destinationKeyFields = Object.keys(keys);
4226
4204
  const keyBindingSourceFields = Object.values(keys).map((p) => sourceAttributeOf(p));
@@ -4242,7 +4220,7 @@ function buildMaintenanceGraphImpl(registry, views) {
4242
4220
  projection: slice.project
4243
4221
  });
4244
4222
  if (slice.predicate) {
4245
- if (!isPathRooted2(slice.predicate.path)) {
4223
+ if (!isPathRooted(slice.predicate.path)) {
4246
4224
  throw new Error(
4247
4225
  `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
4226
  );
@@ -4368,14 +4346,6 @@ export {
4368
4346
  entityWrites,
4369
4347
  getEntityWrites,
4370
4348
  lifecyclePhaseForIntent,
4371
- whenMember,
4372
- ViewRegistry,
4373
- isViewDefinition,
4374
- defineView,
4375
- defineViews,
4376
- defineVersioned,
4377
- isProjectionDefinition,
4378
- defineProjection,
4379
- defineProjections,
4349
+ collectViewDefinitions,
4380
4350
  buildMaintenanceGraph
4381
4351
  };